feat: add v03 user billing service (#1128)
This commit is contained in:
@@ -62,6 +62,7 @@ const ciArtifactIdentityEnvNames = [
|
||||
|
||||
const servicePorts = new Map([
|
||||
["hwlab-cloud-api", 6667],
|
||||
["hwlab-user-billing", 6670],
|
||||
["hwlab-cloud-web", 8080],
|
||||
["hwlab-gateway", 7001],
|
||||
["hwlab-edge-proxy", 6667],
|
||||
@@ -803,8 +804,8 @@ function repoLabelFromRemote(remoteUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServices(serviceIds, catalog) {
|
||||
return resolveDevArtifactServices(repoRoot, serviceIds, SERVICE_IDS, catalog);
|
||||
async function resolveServices(serviceIds, catalog, allowedServiceIds = SERVICE_IDS) {
|
||||
return resolveDevArtifactServices(repoRoot, serviceIds, allowedServiceIds, catalog);
|
||||
}
|
||||
|
||||
function dockerfile(baseImage, port, labels = []) {
|
||||
@@ -863,27 +864,51 @@ function dockerfile(baseImage, port, labels = []) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function envReuseDockerfile(baseImage, labels = [], recipe) {
|
||||
function envReuseDockerfile(baseImage, labels = [], recipe, service = null) {
|
||||
const normalizedRecipe = normalizeEnvReuseRecipe(recipe);
|
||||
const runtimeRoot = normalizedRecipe.runtimeNodeModulesPath.replace(/\/node_modules$/u, "");
|
||||
const downloadEnvPrefix = shellEnvPrefix(downloadEnvPairs(normalizedRecipe.downloadStack));
|
||||
const npmConfigScript = npmDownloadConfigScript(normalizedRecipe.downloadStack);
|
||||
const goService = service?.runtimeKind === "go-service" || service?.artifactKind === "go-service";
|
||||
const goCacheEnabled = Boolean(goService && normalizedRecipe.runtimeGoModCachePath && normalizedRecipe.runtimeGoBuildCachePath);
|
||||
const goProxy = normalizedRecipe.downloadStack.goProxy || "https://proxy.golang.org,direct";
|
||||
const goEnv = goCacheEnabled ? [
|
||||
`ENV GOMODCACHE=${normalizedRecipe.runtimeGoModCachePath}`,
|
||||
`ENV GOCACHE=${normalizedRecipe.runtimeGoBuildCachePath}`,
|
||||
`ENV GOPROXY=${goProxy}`,
|
||||
`ENV GOTOOLCHAIN=${normalizedRecipe.goToolchain || "local"}`
|
||||
] : [];
|
||||
const goBuildEnvPrefix = goCacheEnabled ? shellEnvPrefix([
|
||||
["GOMODCACHE", normalizedRecipe.runtimeGoModCachePath],
|
||||
["GOCACHE", normalizedRecipe.runtimeGoBuildCachePath],
|
||||
["GOPROXY", goProxy],
|
||||
["GOTOOLCHAIN", normalizedRecipe.goToolchain || "local"]
|
||||
]) : "";
|
||||
const goDownloadScript = goCacheEnabled
|
||||
? `RUN ${downloadEnvPrefix}${goBuildEnvPrefix}set -eu; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; go mod download`
|
||||
: null;
|
||||
const goReadiness = goCacheEnabled
|
||||
? `; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; command -v go >/dev/null; go version >/tmp/hwlab-env-go-version.txt`
|
||||
: "";
|
||||
const downloadDependencyScript = `set -eu; ${npmConfigScript}; if [ -f package-lock.json ]; then npm ci --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi; if command -v bun >/dev/null 2>&1; then bun_path="$(command -v bun)"; if [ "$bun_path" != "/usr/local/bin/bun" ]; then ln -sf "$bun_path" /usr/local/bin/bun; fi; fi; if [ ! -x /usr/local/bin/bun ]; then bun_pkg=""; case "$(uname -m)" in x86_64|amd64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.x64}@${normalizedRecipe.bunVersion}" ;; aarch64|arm64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.arm64}@${normalizedRecipe.bunVersion}" ;; *) echo "unsupported bun architecture: $(uname -m)" >&2; false ;; esac; npm install --no-save --omit=dev --include=optional --ignore-scripts "$bun_pkg"; bun_bin="node_modules/\${bun_pkg%@*}/bin/bun"; test -x "$bun_bin"; ln -sf "${runtimeRoot}/$bun_bin" /usr/local/bin/bun; fi`;
|
||||
const osPackageScript = `set -eu; apt-get update; apt-get install -y --no-install-recommends ${normalizedRecipe.osPackages.map(shellQuote).join(" ")}; rm -rf /var/lib/apt/lists/*`;
|
||||
const aliasScript = normalizedRecipe.hwpodAliases.map((alias) => `printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/${alias.script} \"$@\"' > /usr/local/bin/${alias.command}; chmod 755 /usr/local/bin/${alias.command}`).join("; ");
|
||||
const readinessScript = `set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; test -d ${shellQuote(normalizedRecipe.runtimeNodeModulesPath)}; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt; ${aliasScript}`;
|
||||
const readinessScript = `set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; test -d ${shellQuote(normalizedRecipe.runtimeNodeModulesPath)}; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt${goReadiness}; ${aliasScript}`;
|
||||
return [
|
||||
`FROM ${baseImage}`,
|
||||
`WORKDIR ${runtimeRoot}`,
|
||||
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
|
||||
"COPY package.json ./package.json",
|
||||
"COPY package-lock.json* ./",
|
||||
...(goCacheEnabled ? ["COPY go.mod ./go.mod", "COPY go.sum* ./"] : []),
|
||||
`RUN ${downloadEnvPrefix}${osPackageScript}`,
|
||||
`RUN ${downloadEnvPrefix}${downloadDependencyScript}`,
|
||||
...(goDownloadScript ? [goDownloadScript] : []),
|
||||
`COPY ${normalizedRecipe.launcherPath} /usr/local/bin/hwlab-env-reuse-launcher.ts`,
|
||||
`RUN ${readinessScript}`,
|
||||
"ENV HWLAB_RUNTIME_MODE=env-reuse-git-mirror-checkout",
|
||||
`ENV HWLAB_RUNTIME_NODE_MODULES=${normalizedRecipe.runtimeNodeModulesPath}`,
|
||||
...goEnv,
|
||||
"ENV PATH=/usr/local/bin:$PATH",
|
||||
"CMD [\"/usr/local/bin/bun\", \"/usr/local/bin/hwlab-env-reuse-launcher.ts\"]",
|
||||
""
|
||||
@@ -897,6 +922,9 @@ function normalizeEnvReuseRecipe(recipe) {
|
||||
bunVersion: String(recipe.bunVersion ?? "").trim(),
|
||||
launcherPath: normalizeRepoPath(recipe.launcherPath),
|
||||
runtimeNodeModulesPath: normalizeAbsolutePath(recipe.runtimeNodeModulesPath),
|
||||
runtimeGoModCachePath: normalizeAbsolutePath(recipe.runtimeGoModCachePath),
|
||||
runtimeGoBuildCachePath: normalizeAbsolutePath(recipe.runtimeGoBuildCachePath),
|
||||
goToolchain: String(recipe.goToolchain ?? "").trim(),
|
||||
downloadStack: normalizeDownloadStack(recipe.downloadStack),
|
||||
hwpodAliases: normalizeHwpodAliases(recipe.hwpodAliases)
|
||||
};
|
||||
@@ -917,6 +945,7 @@ function normalizeDownloadStack(value) {
|
||||
noProxy: normalizeStringList(value.noProxy),
|
||||
npmRegistry: normalizeOptionalHttpUrl(value.npmRegistry),
|
||||
npmFetchTimeoutMs: Number(value.npmFetchTimeoutMs),
|
||||
goProxy: String(value.goProxy ?? "").trim(),
|
||||
bunPackages: {
|
||||
x64: normalizePackageName(value.bunPackages?.x64),
|
||||
arm64: normalizePackageName(value.bunPackages?.arm64)
|
||||
@@ -1242,7 +1271,7 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat
|
||||
const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : [];
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels, service.envReuseRecipe));
|
||||
await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels, service.envReuseRecipe, service));
|
||||
const argsList = [
|
||||
...buildkitAddrArgs,
|
||||
"build",
|
||||
@@ -2277,7 +2306,8 @@ async function main() {
|
||||
baseImage: args.baseImage ?? undefined,
|
||||
...(args.servicesExplicit ? { services: args.services } : {})
|
||||
});
|
||||
const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog), ciPlan);
|
||||
const allowedServiceIds = isRuntimeArtifactLane(args.lane) ? runtimeLaneServiceIdsFromDeploy(deployManifest, args.lane) : SERVICE_IDS;
|
||||
const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog, allowedServiceIds), ciPlan);
|
||||
const catalogByServiceId = new Map((catalog?.services ?? []).map((service) => [service.serviceId, service]));
|
||||
const affectedServiceIds = new Set(ciPlan?.affectedServices ?? []);
|
||||
const plannedBuildServices = Array.isArray(ciPlan?.buildServices) ? ciPlan.buildServices : null;
|
||||
|
||||
@@ -642,6 +642,10 @@ function v02ObservableService(deploy, serviceId) {
|
||||
return v02ObservableServicesForDeploy(deploy).find((item) => item.serviceId === serviceId) ?? null;
|
||||
}
|
||||
|
||||
function runtimeLaneObservableService(deploy, lane, serviceId) {
|
||||
return runtimeLaneObservableServicesForDeploy(deploy, lane).find((item) => item.serviceId === serviceId) ?? null;
|
||||
}
|
||||
|
||||
function runtimeLaneObservableServiceIdsForDeploy(deploy, lane) {
|
||||
return new Set(runtimeLaneObservableServicesForDeploy(deploy, lane).map((item) => item.serviceId));
|
||||
}
|
||||
@@ -943,9 +947,9 @@ function v02MetricsSidecarAnnotations(metricsSidecarSha256) {
|
||||
return metricsSidecarSha256 ? { "hwlab.pikastech.local/metrics-sidecar-sha256": metricsSidecarSha256 } : {};
|
||||
}
|
||||
|
||||
function v02MetricsSidecarContainer({ deploy, serviceId, namespace, gitopsTarget }) {
|
||||
const service = v02ObservableService(deploy, serviceId);
|
||||
assert.ok(service, `unknown v0.2 observable service ${serviceId}`);
|
||||
function v02MetricsSidecarContainer({ deploy, serviceId, namespace, gitopsTarget, profile = "v02" }) {
|
||||
const service = runtimeLaneObservableService(deploy, profile, serviceId);
|
||||
assert.ok(service, `unknown ${profile} observable service ${serviceId}`);
|
||||
const extraMetricsEnv = serviceId === "hwlab-cloud-api"
|
||||
? [
|
||||
{ name: "HWLAB_METRICS_EXTRA_URL", value: `http://127.0.0.1:${service.port}/v1/web-performance/metrics` },
|
||||
|
||||
@@ -431,7 +431,7 @@ function refreshDocuments({ deploy, catalog, target, publishRecords, serviceInve
|
||||
const records = publishRecords?.records ?? null;
|
||||
const requiredIds = new Set(publishRecords?.requiredServiceIds ?? serviceInventory.requiredServiceIds);
|
||||
const catalogByService = new Map(catalog.services.map((service) => [service.serviceId, service]));
|
||||
const deployByService = new Map(deploy.services.map((service) => [service.serviceId, service]));
|
||||
const deployByService = deployServiceMapForLane(deploy, lane, serviceIds);
|
||||
const inventoryByService = new Map(serviceInventory.services.map((service) => [service.serviceId, service]));
|
||||
const refreshedServices = [];
|
||||
const nextCatalogServices = [];
|
||||
@@ -539,6 +539,17 @@ function refreshDocuments({ deploy, catalog, target, publishRecords, serviceInve
|
||||
return refreshedServices;
|
||||
}
|
||||
|
||||
function deployServiceMapForLane(deploy, lane, serviceIds) {
|
||||
const base = new Map((deploy.services ?? []).map((service) => [service.serviceId, service]));
|
||||
if (!isRuntimeArtifactLane(lane)) return base;
|
||||
const overlay = new Map((laneDeployConfig(deploy, lane)?.services ?? []).map((service) => [service.serviceId, service]));
|
||||
return new Map(serviceIds.map((serviceId) => [serviceId, {
|
||||
serviceId,
|
||||
...(base.get(serviceId) ?? {}),
|
||||
...(overlay.get(serviceId) ?? {})
|
||||
}]));
|
||||
}
|
||||
|
||||
function publishReportProvenance(publishReport, fallbackPath) {
|
||||
const producer = publishReport?.artifactPublish?.producer;
|
||||
if (producer?.kind && producer?.runId) return `${producer.kind}:${producer.runId}`;
|
||||
@@ -566,7 +577,7 @@ async function main() {
|
||||
const serviceIds = serviceIdsForLane(args.lane, deploy);
|
||||
const publishRecords = publishReport ? publishRecordsFromReport(publishReport, target, serviceIds) : null;
|
||||
const serviceInventory = publishReport?.artifactPublish?.serviceInventory ?? serviceInventoryFromServices(
|
||||
await resolveDevArtifactServices(repoRoot, serviceIds, SERVICE_IDS)
|
||||
await resolveDevArtifactServices(repoRoot, serviceIds, serviceIds)
|
||||
);
|
||||
const services = refreshDocuments({
|
||||
deploy,
|
||||
|
||||
@@ -18,6 +18,11 @@ export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
|
||||
"package-lock.json"
|
||||
]);
|
||||
|
||||
export const GO_RUNTIME_DEP_PATHS = Object.freeze([
|
||||
"go.mod",
|
||||
"go.sum"
|
||||
]);
|
||||
|
||||
export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
@@ -75,9 +80,11 @@ export async function createCiPlan(options = {}) {
|
||||
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, laneConfig, lane);
|
||||
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
|
||||
const globalChange = classifyGlobalChange(normalizedChangedPaths);
|
||||
const hasGoService = componentModels.some((model) => model.runtimeKind === "go-service");
|
||||
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
|
||||
...envReuseRecipe.additionalEnvPaths,
|
||||
...DEFAULT_RUNTIME_DEP_PATHS
|
||||
...DEFAULT_RUNTIME_DEP_PATHS,
|
||||
...(hasGoService ? GO_RUNTIME_DEP_PATHS : [])
|
||||
], { semanticPackageJson: true });
|
||||
const catalogByServiceId = new Map((artifactCatalog?.services ?? []).map((service) => [service.serviceId, service]));
|
||||
|
||||
@@ -297,12 +304,20 @@ export function componentModelsForServices(serviceIds, laneConfig = null, lane =
|
||||
healthPort: declaration.healthPort,
|
||||
componentPaths: uniqueSorted(declaration.componentPaths.map(normalizeRepoPath)),
|
||||
sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)),
|
||||
runtimeDeps: uniqueSorted(DEFAULT_RUNTIME_DEP_PATHS.map(normalizeRepoPath)),
|
||||
runtimeDeps: runtimeDependencyPathsForDeclaration(declaration),
|
||||
buildSystemPaths: uniqueSorted(envReuseRecipeForLaneConfig(laneConfig, lane).additionalEnvPaths)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeDependencyPathsForDeclaration(declaration) {
|
||||
const paths = [...DEFAULT_RUNTIME_DEP_PATHS];
|
||||
if (declaration.runtimeKind === "go-service" || declaration.artifactKind === "go-service") {
|
||||
paths.push(...GO_RUNTIME_DEP_PATHS);
|
||||
}
|
||||
return uniqueSorted(paths.map(normalizeRepoPath));
|
||||
}
|
||||
|
||||
function serviceDeclarationFor(laneConfig, serviceId, lane) {
|
||||
const configured = laneConfig?.serviceDeclarations?.[serviceId];
|
||||
const runtimeKind = requireDeclarationString(configured?.runtimeKind, lane, `serviceDeclarations.${serviceId}.runtimeKind`);
|
||||
@@ -351,6 +366,9 @@ function envReuseRecipeForLaneConfig(laneConfig, lane = "v02") {
|
||||
const bunVersion = requireDeclarationString(configured.bunVersion, lane, "envRecipe.bunVersion");
|
||||
const launcherPath = normalizeRepoPath(requireDeclarationString(configured.launcherPath, lane, "envRecipe.launcherPath"));
|
||||
const runtimeNodeModulesPath = normalizeAbsolutePath(configured.runtimeNodeModulesPath);
|
||||
const runtimeGoModCachePath = normalizeAbsolutePath(configured.runtimeGoModCachePath);
|
||||
const runtimeGoBuildCachePath = normalizeAbsolutePath(configured.runtimeGoBuildCachePath);
|
||||
const goToolchain = normalizeDeclarationString(configured.goToolchain);
|
||||
if (!runtimeNodeModulesPath) throw new Error(`deploy.lanes.${lane}.envRecipe.runtimeNodeModulesPath must be absolute`);
|
||||
const additionalEnvPaths = uniqueSorted(normalizeStringList(configured.additionalEnvPaths, []).map(normalizeRepoPath));
|
||||
const launcherInputPaths = uniqueSorted([
|
||||
@@ -367,6 +385,9 @@ function envReuseRecipeForLaneConfig(laneConfig, lane = "v02") {
|
||||
bunVersion,
|
||||
launcherPath,
|
||||
runtimeNodeModulesPath,
|
||||
runtimeGoModCachePath,
|
||||
runtimeGoBuildCachePath,
|
||||
goToolchain,
|
||||
additionalEnvPaths,
|
||||
launcherInputPaths,
|
||||
downloadStack,
|
||||
@@ -376,6 +397,9 @@ function envReuseRecipeForLaneConfig(laneConfig, lane = "v02") {
|
||||
bunVersion,
|
||||
launcherPath,
|
||||
runtimeNodeModulesPath,
|
||||
runtimeGoModCachePath,
|
||||
runtimeGoBuildCachePath,
|
||||
goToolchain,
|
||||
additionalEnvPaths,
|
||||
downloadStack,
|
||||
hwpodAliases
|
||||
@@ -389,6 +413,7 @@ function normalizeDownloadStack(value, lane) {
|
||||
const httpProxy = normalizeOptionalHttpUrl(configured.httpProxy, `deploy.lanes.${lane}.envRecipe.downloadStack.httpProxy`);
|
||||
const httpsProxy = normalizeOptionalHttpUrl(configured.httpsProxy, `deploy.lanes.${lane}.envRecipe.downloadStack.httpsProxy`);
|
||||
const npmRegistry = normalizeOptionalHttpUrl(configured.npmRegistry, `deploy.lanes.${lane}.envRecipe.downloadStack.npmRegistry`);
|
||||
const goProxy = normalizeDeclarationString(configured.goProxy);
|
||||
const npmFetchTimeoutMs = Number(configured.npmFetchTimeoutMs);
|
||||
const bunPackages = configured.bunPackages && typeof configured.bunPackages === "object" ? configured.bunPackages : null;
|
||||
const x64 = normalizePackageName(bunPackages?.x64);
|
||||
@@ -404,6 +429,7 @@ function normalizeDownloadStack(value, lane) {
|
||||
noProxy: normalizeStringList(configured.noProxy, []),
|
||||
npmRegistry,
|
||||
npmFetchTimeoutMs,
|
||||
goProxy,
|
||||
bunPackages: { x64, arm64 }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +85,17 @@ export async function resolveDevArtifactService(repoRoot, serviceId, catalog = n
|
||||
});
|
||||
}
|
||||
|
||||
const goCmdPath = `cmd/${serviceId}/main.go`;
|
||||
if (await pathExists(repoRoot, goCmdPath)) {
|
||||
return withServicePublishPolicy({
|
||||
serviceId,
|
||||
runtimeKind: "go-service",
|
||||
entrypoint: goCmdPath,
|
||||
implementationState: "repo-entrypoint",
|
||||
sourceState
|
||||
});
|
||||
}
|
||||
|
||||
if (serviceId === "hwlab-cloud-web") {
|
||||
return withServicePublishPolicy({
|
||||
serviceId,
|
||||
|
||||
Reference in New Issue
Block a user