From 9f8142d6f536bf4d6f5c740939dd9b9f6e8dab20 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 17 Sep 2026 17:53:58 -0400 Subject: [PATCH 1/2] fix: initialize shared dependency caches before scoped startup --- docs/cli.md | 14 +- docs/lifecycle.md | 4 +- src/backends/runtime-backend.ts | 11 +- src/commands/project.ts | 137 +++++++++++--- src/lib/cli-result.ts | 1 + src/lib/dependency-cache-bootstrap.ts | 33 ++++ src/lib/dependency-cache.ts | 48 +++++ tests/dependency-cache.test.ts | 23 +++ tests/e2e/run.ts | 2 + tests/e2e/scenarios/dependency-cache.ts | 228 ++++++++++++++++++++++++ tests/project-up-startup-state.test.ts | 121 ++++++++++++- tests/runtime-backend.test.ts | 37 +++- 12 files changed, 624 insertions(+), 35 deletions(-) create mode 100644 src/lib/dependency-cache-bootstrap.ts create mode 100644 tests/e2e/scenarios/dependency-cache.ts diff --git a/docs/cli.md b/docs/cli.md index cbcf4a61..0fe4dd4f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -105,7 +105,7 @@ route for it. - Container-to-container traffic: use Compose DNS rather than routing back through Caddy. Service-scoped runtime changes do not run project-wide lifecycle hooks and do not start Compose -dependencies implicitly: +dependencies implicitly, except declared shared-cache installers needed by the selected services: ```bash hack up api worker --env qa --detach @@ -144,7 +144,17 @@ services: Hack generates a content-addressed volume name from the declared inputs. Branch instances adopt an existing compatible volume automatically; a lockfile or runtime change selects a new volume. No -service name such as `deps` is special. +service name such as `deps` is special. `hack run` resolves the same cache as `up` and `restart`. + +Before a scoped `up`, `restart`, or a consumer `run`, Hack executes each needed cache installer +with the same Compose files and selected service environment, using `run --rm --no-deps`. +Installers must be idempotent and coordinate concurrent writers (for example, a lock and a ready +marker inside the volume). They run even for warm caches so an empty or interrupted cache cannot +be mistaken for a ready one. No project lifecycle hook or unrelated dependency is started. +A failed installer leaves existing consumer containers untouched; JSON lifecycle commands return +`E_DEPENDENCY_BOOTSTRAP_FAILED`. Automatic initialization has a ten-minute process deadline. +`hack exec` continues to use the existing container and its mounted cache until that container +is explicitly recreated. ## Branch instances and linked worktrees diff --git a/docs/lifecycle.md b/docs/lifecycle.md index fa6c6c3f..02cc8e43 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -209,7 +209,9 @@ stopped. `hack restart` preserves the same guard semantics during its down phase current Compose runtime until preflight succeeds. It then force-recreates services and attempts a repair start if recreation fails. This avoids destroying a healthy stack before env and registry checks have passed. `hack restart ` is service-scoped: it skips project-wide lifecycle -hooks, uses `--no-deps`, and verifies only the selected services. +hooks, initializes declared shared dependency caches before replacing their selected consumers, +uses `--no-deps`, and verifies only the selected services. Failed cache initialization leaves the +existing consumers running. From the primary checkout, it targets only the base Compose/lifecycle instance. A linked worktree uses its isolated derived branch instance, and `--branch ` targets only that explicit branch. diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index c58fcb08..3b42ecc6 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -43,6 +43,8 @@ export interface RuntimePsOptions extends RuntimeBaseOptions { } export interface RuntimeRunOptions extends RuntimeBaseOptions { + readonly forwardSignals?: boolean; + readonly timeoutMs?: number; readonly service: string; readonly noDeps?: boolean; readonly workdir?: string; @@ -166,7 +168,14 @@ export const composeRuntimeBackend: RuntimeBackend = { opts.service, ...(opts.cmdArgs.length > 0 ? opts.cmdArgs : []), ]; - return await run(cmd, { cwd: opts.cwd, stdin: "inherit", env: opts.env }); + return await run(cmd, { + cwd: opts.cwd, + stdin: "inherit", + env: opts.env, + stdout: opts.routeStdoutToStderr ? "stderr" : "inherit", + timeoutMs: opts.timeoutMs, + forwardSignals: opts.forwardSignals, + }); }, async exec(opts) { const cmd = [ diff --git a/src/commands/project.ts b/src/commands/project.ts index 7084b291..d7672968 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -111,7 +111,11 @@ import { classifyComposeStartupState, } from "../lib/compose-startup-state.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; -import { resolveDependencyCacheOverride } from "../lib/dependency-cache.ts"; +import { + resolveDependencyCacheBootstrapServices, + resolveDependencyCacheOverride, +} from "../lib/dependency-cache.ts"; +import { bootstrapDependencyCaches } from "../lib/dependency-cache-bootstrap.ts"; import { removeDisposableCacheVolumes } from "../lib/disposable-cache-volumes.ts"; import { parseDurationMs } from "../lib/duration.ts"; import { @@ -5980,10 +5984,20 @@ async function runUpCommand({ }); const serviceScoped = requestedServices.length > 0; const targetServices = serviceScoped ? requestedServices : allServiceNames; + const cacheBootstrapServices = serviceScoped + ? await resolveDependencyCacheBootstrapServices({ + composeFile: project.composeFile, + cache: dependencyCache, + targetServices, + }) + : []; + const preparedServices = [ + ...new Set([...targetServices, ...cacheBootstrapServices]), + ]; const envOverrides = await resolveComposeEnvOverrides({ project, projectName, - targetServices, + targetServices: preparedServices, allServiceNames, envName, }); @@ -5996,7 +6010,7 @@ async function runUpCommand({ await assertRegistryCredentialsAvailable({ projectRoot: project.projectRoot, composeFile: project.composeFile, - targetServices, + targetServices: preparedServices, envByService: envOverrides.preflightEnvByService, }); @@ -6061,6 +6075,27 @@ async function runUpCommand({ lifecycleSignalCleanup ?? installLifecycleSignalCleanup({ cleanup: lifecycleCleanup }); try { + const bootstrapCode = await bootstrapDependencyCaches({ + services: cacheBootstrapServices, + composeFiles: composeFilesWithEnv, + composeProject: composeProjectName, + profiles, + cwd: dirname(project.composeFile), + env: envOverrides.env, + }); + if (bootstrapCode !== 0) { + if (json) { + return emitLifecycleResult({ + result: errorResult({ + code: "E_DEPENDENCY_BOOTSTRAP_FAILED", + message: + "Dependency cache initialization failed; consumers were not changed", + }), + exitCode: bootstrapCode, + }); + } + return bootstrapCode; + } const upCode = await composeRuntimeBackend.up({ composeFiles: composeFilesWithEnv, composeProject: composeProjectName, @@ -6811,6 +6846,7 @@ type TargetedServiceRestartResult = readonly errorCode: | "E_COMPOSE_FAILED" | "E_STARTUP_INCOMPLETE" + | "E_DEPENDENCY_BOOTSTRAP_FAILED" | "E_STARTUP_TIMEOUT"; readonly message: string; readonly running: readonly string[]; @@ -6857,25 +6893,33 @@ async function runTargetedServiceRestart(opts: { aliasHost, }) : [opts.project.composeFile]; + const dependencyCache = await resolveDependencyCacheOverride({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + projectName: opts.projectName, + composeFile: opts.project.composeFile, + }); + const cacheBootstrapServices = await resolveDependencyCacheBootstrapServices({ + composeFile: opts.project.composeFile, + cache: dependencyCache, + targetServices: opts.services, + }); + const preparedServices = [ + ...new Set([...opts.services, ...cacheBootstrapServices]), + ]; const envOverrides = await resolveComposeEnvOverrides({ project: opts.project, projectName: opts.projectName, - targetServices: opts.services, + targetServices: preparedServices, allServiceNames: opts.allServiceNames, envName: opts.envName, }); await assertRegistryCredentialsAvailable({ projectRoot: opts.project.projectRoot, composeFile: opts.project.composeFile, - targetServices: opts.services, + targetServices: preparedServices, envByService: envOverrides.preflightEnvByService, }); - const dependencyCache = await resolveDependencyCacheOverride({ - projectRoot: opts.project.projectRoot, - projectDir: opts.project.projectDir, - projectName: opts.projectName, - composeFile: opts.project.composeFile, - }); const composeFilesWithRuntimeOverrides = [ ...composeFiles, ...(internalOverride ? [internalOverride] : []), @@ -6889,12 +6933,32 @@ async function runTargetedServiceRestart(opts: { aliasHost, composeProject: opts.composeProjectName ?? opts.baseProjectName, }); + const composeFilesWithEnv = [ + ...composeFilesWithRuntimeOverrides, + ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), + ...envOverrides.composeFiles, + ]; + const bootstrapCode = await bootstrapDependencyCaches({ + services: cacheBootstrapServices, + composeFiles: composeFilesWithEnv, + composeProject: opts.composeProjectName, + profiles: opts.profiles, + cwd: dirname(opts.project.composeFile), + env: envOverrides.env, + }); + if (bootstrapCode !== 0) { + return { + ok: false, + code: bootstrapCode, + errorCode: "E_DEPENDENCY_BOOTSTRAP_FAILED", + message: + "Dependency cache initialization failed; consumers were not changed", + running: [], + completed: [], + }; + } const code = await composeRuntimeBackend.up({ - composeFiles: [ - ...composeFilesWithRuntimeOverrides, - ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), - ...envOverrides.composeFiles, - ], + composeFiles: composeFilesWithEnv, composeProject: opts.composeProjectName, profiles: opts.profiles, detach: true, @@ -7557,9 +7621,18 @@ async function handleRun({ const composeFiles = branch ? await resolveBranchComposeFiles({ project, branch, devHost, aliasHost }) : [project.composeFile]; - const composeFilesWithInternal = internalOverride - ? [...composeFiles, internalOverride] - : composeFiles; + const projectName = sanitizeProjectSlug(baseProjectName); + const dependencyCache = await resolveDependencyCacheOverride({ + projectRoot: project.projectRoot, + projectDir: project.projectDir, + projectName, + composeFile: project.composeFile, + }); + const composeFilesWithInternal = [ + ...composeFiles, + ...(internalOverride ? [internalOverride] : []), + ...(dependencyCache.overridePath ? [dependencyCache.overridePath] : []), + ]; const runtimeMetadataOverride = await resolveRuntimeHostMetadataOverride({ project, composeFiles: composeFilesWithInternal, @@ -7569,12 +7642,17 @@ async function handleRun({ composeProject: composeProjectName ?? baseProjectName, }); - const projectName = sanitizeProjectSlug(baseProjectName); + const cacheBootstrapServices = await resolveDependencyCacheBootstrapServices({ + composeFile: project.composeFile, + cache: dependencyCache, + targetServices: [service], + }); + const preparedServices = [...new Set([service, ...cacheBootstrapServices])]; const allServiceNames = await readComposeServiceNames(project.composeFile); const envOverrides = await resolveComposeEnvOverrides({ project, projectName, - targetServices: [service], + targetServices: preparedServices, allServiceNames, envName, }); @@ -7583,6 +7661,23 @@ async function handleRun({ ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), ...envOverrides.composeFiles, ]; + await assertRegistryCredentialsAvailable({ + projectRoot: project.projectRoot, + composeFile: project.composeFile, + targetServices: preparedServices, + envByService: envOverrides.preflightEnvByService, + }); + const bootstrapCode = await bootstrapDependencyCaches({ + services: cacheBootstrapServices, + composeFiles: composeFilesWithEnv, + composeProject: composeProjectName, + profiles, + cwd: dirname(project.composeFile), + env: envOverrides.env, + }); + if (bootstrapCode !== 0) { + return bootstrapCode; + } const stackIsRunning = await resolveCanSkipRunDependencies({ composeFiles: composeFilesWithEnv, composeProjectKey: composeProjectName ?? baseProjectName, diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 0d4a4bd5..007848f2 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -22,6 +22,7 @@ export type HackErrorCode = | "E_SERVICE_NOT_FOUND" | "E_COMPOSE_FAILED" | "E_STARTUP_INCOMPLETE" + | "E_DEPENDENCY_BOOTSTRAP_FAILED" | "E_STARTUP_TIMEOUT" | "E_LIFECYCLE_FAILED" | "E_ENV_KEY_MISSING" diff --git a/src/lib/dependency-cache-bootstrap.ts b/src/lib/dependency-cache-bootstrap.ts new file mode 100644 index 00000000..ca4088a5 --- /dev/null +++ b/src/lib/dependency-cache-bootstrap.ts @@ -0,0 +1,33 @@ +import { + composeRuntimeBackend, + type RuntimeBaseOptions, +} from "../backends/runtime-backend.ts"; + +const BOOTSTRAP_TIMEOUT_MS = 600_000; + +/** Finish idempotent cache initialization before moving any selected consumer. */ +export async function bootstrapDependencyCaches( + opts: RuntimeBaseOptions & { + readonly services: readonly string[]; + } +): Promise { + for (const service of opts.services) { + process.stderr.write(`Initializing dependency cache with ${service}\n`); + const code = await composeRuntimeBackend.run({ + ...opts, + service, + noDeps: true, + cmdArgs: [], + timeoutMs: BOOTSTRAP_TIMEOUT_MS, + forwardSignals: true, + routeStdoutToStderr: true, + }); + if (code !== 0) { + process.stderr.write( + `Dependency cache initialization failed for ${service} (exit ${code}); consumers were not changed\n` + ); + return code; + } + } + return 0; +} diff --git a/src/lib/dependency-cache.ts b/src/lib/dependency-cache.ts index 42f671ff..046eddd7 100644 --- a/src/lib/dependency-cache.ts +++ b/src/lib/dependency-cache.ts @@ -8,6 +8,7 @@ import { writeTextFileIfChanged, } from "./fs.ts"; import { isRecord } from "./guards.ts"; +import { discoverDependencyBootstrapServices } from "./registry-credential-preflight.ts"; const CACHE_VOLUME_LABEL = "hack.dependencies.cache-volume"; const LOCKFILES_LABEL = "hack.dependencies.lockfiles"; @@ -50,6 +51,53 @@ export type DependencyCacheResolution = { readonly inputs: readonly string[]; }; +/** Select only installers for cache volumes mounted by the requested consumers. */ +export async function resolveDependencyCacheBootstrapServices(opts: { + readonly composeFile: string; + readonly cache: DependencyCacheResolution; + readonly targetServices: readonly string[]; +}): Promise { + if (!opts.cache.overridePath) { + return []; + } + const text = await readTextFile(opts.composeFile); + const parsed: unknown = YAML.parse(text ?? ""); + if (!(isRecord(parsed) && isRecord(parsed.services))) { + return []; + } + const installers = new Set(await discoverDependencyBootstrapServices(opts)); + const services = parsed.services; + const requiredVolumes = opts.cache.volumes.filter((volume) => + opts.targetServices.some((target) => { + if (volume.services.includes(target)) { + return false; + } + const service = services[target]; + return ( + isRecord(service) && + Array.isArray(service.volumes) && + service.volumes.some( + (mount: unknown) => resolveVolumeSource(mount) === volume.logicalName + ) + ); + }) + ); + return [ + ...new Set( + requiredVolumes + .flatMap((volume) => volume.services) + .filter((service) => installers.has(service)) + ), + ].sort(); +} + +function resolveVolumeSource(mount: unknown): unknown { + if (typeof mount === "string") { + return mount.split(":")[0]; + } + return isRecord(mount) && mount.type === "volume" ? mount.source : null; +} + function parseCsv(value: unknown): readonly string[] { if (typeof value !== "string") { return []; diff --git a/tests/dependency-cache.test.ts b/tests/dependency-cache.test.ts index ddaf7dfa..a775e498 100644 --- a/tests/dependency-cache.test.ts +++ b/tests/dependency-cache.test.ts @@ -70,3 +70,26 @@ test("dependency cache shares a lockfile and runtime keyed volume", async () => first.volumes[0]?.resolvedName ); }); + +test("identical inputs share cache across checkouts and runtime changes isolate it", async () => { + const firstProject = await createProject(); + const secondProject = await createProject(); + const first = await resolveDependencyCacheOverride({ + ...firstProject, + projectName: "shared", + }); + const second = await resolveDependencyCacheOverride({ + ...secondProject, + projectName: "shared", + }); + expect(second.volumes).toEqual(first.volumes); + await writeFile( + resolve(secondProject.projectRoot, "package.json"), + '{"packageManager":"bun@1.4.0"}\n' + ); + const changed = await resolveDependencyCacheOverride({ + ...secondProject, + projectName: "shared", + }); + expect(changed.fingerprint).not.toBe(first.fingerprint); +}); diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 0710ac49..ec9d7c7a 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -2,6 +2,7 @@ import { runIsolationCanary, runScenarios, type Scenario } from "./harness.ts"; import { agentDocsSyncScenario } from "./scenarios/agent-docs-sync.ts"; import { automationCheckScenario } from "./scenarios/automation-check.ts"; import { cachePruneScenario } from "./scenarios/cache-prune.ts"; +import { dependencyCacheScenario } from "./scenarios/dependency-cache.ts"; import { doctorScenario } from "./scenarios/doctor.ts"; import { envSecretsScenario } from "./scenarios/env-secrets.ts"; import { initScenario } from "./scenarios/init.ts"; @@ -41,6 +42,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ doctorScenario, lifecycleSessionRecoveryScenario, cachePruneScenario, + dependencyCacheScenario, upDownScenario, lifecycleHostProcessScenario, worktreeParallelUpScenario, diff --git a/tests/e2e/scenarios/dependency-cache.ts b/tests/e2e/scenarios/dependency-cache.ts new file mode 100644 index 00000000..30c89362 --- /dev/null +++ b/tests/e2e/scenarios/dependency-cache.ts @@ -0,0 +1,228 @@ +import { join } from "node:path"; +import { createMonorepoFixture } from "../fixture.ts"; +import { expect, expectExit, runCommand, type Scenario } from "../harness.ts"; +import { downBestEffort, requireDockerPreconditions } from "./docker-shared.ts"; + +/** Exercise actual mounts and installer failure before consumer replacement. */ +export const dependencyCacheScenario: Scenario = { + name: "dependency-cache", + tier: "docker", + summary: "run/up/restart share initialized caches across lockfile changes", + run: async (ctx) => { + await requireDockerPreconditions({ ctx }); + const fixture = await createMonorepoFixture({ + parentDir: ctx.tempRoot, + withHackConfig: true, + lifecycle: { disableInternal: true }, + }); + const project = `${fixture.name}--cache`; + await Bun.write(join(fixture.root, "bun.lock"), "lock-one\n"); + await Bun.write( + join(fixture.root, "initialize.sh"), + `#!/bin/sh +set -eu +test ! -e /workspace/fail-install +if cmp -s /workspace/bun.lock /cache/lock; then exit 0; fi +cp /workspace/bun.lock /cache/lock +` + ); + await Bun.write( + join(fixture.hackDir, "docker-compose.yml"), + `name: ${fixture.name} +services: + deps: + image: alpine:3.20 + command: [sh, /workspace/initialize.sh] + labels: + hack.dependencies.bootstrap: "true" + hack.dependencies.cache-volume: dependencies + hack.dependencies.lockfiles: bun.lock + volumes: + - ..:/workspace:ro + - dependencies:/cache + app: + image: alpine:3.20 + command: [sh, -c, "cp /cache/lock /tmp/loaded && exec sleep 3600"] + volumes: + - dependencies:/cache:ro + depends_on: + deps: + condition: service_completed_successfully + unrelated: + image: alpine:3.20 + command: [sleep, "3600"] +volumes: + dependencies: {} +` + ); + const cli = async (args: readonly string[]) => + await ctx.cli({ + args: [args[0]!, "--branch", "cache", ...args.slice(1)], + cwd: fixture.root, + timeoutMs: 180_000, + }); + const probe = async (service: string, args: readonly string[]) => + await runCommand({ + argv: ["docker", "exec", `${project}-${service}-1`, ...args], + cwd: fixture.root, + }); + const checkLoaded = async (expected: string) => { + const result = await probe("app", ["cat", "/tmp/loaded"]); + expectExit({ + result, + codes: [0], + message: "consumer must be running with initialized dependencies", + }); + expect({ + that: result.stdout.trim() === expected, + message: `consumer loaded ${expected}`, + result, + }); + }; + const rememberVolume = async () => { + const result = await runCommand({ + argv: [ + "docker", + "inspect", + `${project}-app-1`, + "--format", + '{{range .Mounts}}{{if eq .Destination "/cache"}}{{.Name}}{{end}}{{end}}', + ], + cwd: fixture.root, + }); + expectExit({ + result, + codes: [0], + message: "inspect owned consumer mount", + }); + const name = result.stdout.trim(); + expect({ + that: name.startsWith(`hack-cache-${fixture.name}-dependencies-`), + message: "consumer uses fingerprinted cache", + result, + }); + return name; + }; + try { + const cold = await cli(["up", "--json", "app", "unrelated"]); + expectExit({ + result: cold, + codes: [0], + message: "cold scoped up initializes dependencies", + }); + expect({ + that: JSON.parse(cold.stdout).ok === true, + message: "bootstrap output preserves JSON stdout", + result: cold, + }); + await checkLoaded("lock-one"); + const firstVolume = await rememberVolume(); + const installer = await cli(["run", "deps", "cat", "/cache/lock"]); + expectExit({ + result: installer, + codes: [0], + message: "one-off installer sees consumer cache", + }); + expect({ + that: installer.stdout.trim() === "lock-one", + message: "run uses identical cache", + result: installer, + }); + const consumerRun = await ctx.cli({ + args: ["run", "--branch", "cache", "app", "cat", "/cache/lock"], + cwd: fixture.root, + env: { HACK_LOGGER: "clack" }, + timeoutMs: 180_000, + }); + expectExit({ + result: consumerRun, + codes: [0], + message: "consumer run bootstraps its cache", + }); + expect({ + that: consumerRun.stdout.trim() === "lock-one", + message: "Clack bootstrap diagnostics stay off consumer stdout", + result: consumerRun, + }); + await cli(["run", "deps", "touch", "/cache/warm-sentinel"]); + expectExit({ + result: await cli(["restart", "app"]), + codes: [0], + message: "warm restart reuses cache", + }); + expectExit({ + result: await probe("app", ["test", "-f", "/cache/warm-sentinel"]), + codes: [0], + message: "warm cache preserved", + }); + expect({ + that: (await rememberVolume()) === firstVolume, + message: "warm volume identity preserved", + }); + await Bun.write(join(fixture.root, "bun.lock"), "lock-two\n"); + expectExit({ + result: await cli(["restart", "app"]), + codes: [0], + message: "restart initializes new fingerprint", + }); + await checkLoaded("lock-two"); + expect({ + that: (await rememberVolume()) !== firstVolume, + message: "lock change selects distinct initialized cache", + }); + await Bun.write(join(fixture.root, "bun.lock"), "lock-three\n"); + expectExit({ + result: await cli(["up", "--detach", "app"]), + codes: [0], + message: "scoped up initializes changed fingerprint", + }); + await checkLoaded("lock-three"); + await rememberVolume(); + await Bun.write(join(fixture.root, "bun.lock"), "failed-lock\n"); + await Bun.write(join(fixture.root, "fail-install"), "fail"); + for (const operation of [ + ["restart", "app"], + ["up", "--detach", "app"], + ]) { + const failed = await cli(operation); + expect({ + that: failed.exitCode !== 0, + message: "installer failure rejects consumer replacement", + result: failed, + }); + await checkLoaded("lock-three"); + } + expectExit({ + result: await probe("unrelated", ["true"]), + codes: [0], + message: "unrelated service remains running", + }); + ctx.log( + "verified cold and warm caches, run mount parity, lockfile transitions, and failure isolation" + ); + } finally { + await downBestEffort({ ctx, fixture, branches: ["cache"] }); + // Only this random fixture's volumes, including a failed initialization. + const volumes = await runCommand({ + argv: [ + "docker", + "volume", + "ls", + "--format", + "{{.Name}}", + "--filter", + `name=hack-cache-${fixture.name}-dependencies-`, + ], + cwd: fixture.root, + }); + for (const name of volumes.stdout.trim().split("\n")) { + if (name.startsWith(`hack-cache-${fixture.name}-dependencies-`)) { + await runCommand({ + argv: ["docker", "volume", "rm", name], + cwd: fixture.root, + }); + } + } + } + }, +}; diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts index 35ff583c..0a750828 100644 --- a/tests/project-up-startup-state.test.ts +++ b/tests/project-up-startup-state.test.ts @@ -2,7 +2,11 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; - +import { YAML } from "bun"; +import type { + RuntimeRunOptions, + RuntimeUpOptions, +} from "../src/backends/runtime-backend.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; import { PROJECT_COMPOSE_FILENAME, @@ -17,6 +21,11 @@ const upEnvs: Array> | undefined> = []; const psEnvs: Array> | undefined> = []; const upServiceSelections: Array = []; const tempDirs = new Set(); +const runtimeCalls: Array< + | { kind: "run"; opts: RuntimeRunOptions } + | { kind: "up"; opts: RuntimeUpOptions } +> = []; +let runExitCode = 0; const originalHackHome = process.env.HACK_HOME; const originalComposeProfiles = process.env.COMPOSE_PROFILES; let autoBranch: string | null = null; @@ -52,10 +61,8 @@ const runtimeBackendMock = await registerScopedModuleMock({ overrides: { composeRuntimeBackend: { name: "compose", - up: async (opts: { - readonly env?: Readonly>; - readonly services?: readonly string[]; - }) => { + up: async (opts: RuntimeUpOptions) => { + runtimeCalls.push({ kind: "up", opts }); upEnvs.push(opts.env); upServiceSelections.push(opts.services); return 0; @@ -72,7 +79,10 @@ const runtimeBackendMock = await registerScopedModuleMock({ }; }, ps: async () => 0, - run: async () => 0, + run: async (opts: RuntimeRunOptions) => { + runtimeCalls.push({ kind: "run", opts }); + return runExitCode; + }, exec: async () => 0, }, }, @@ -106,7 +116,7 @@ const loggerMock = await registerScopedModuleMock({ }, }); -const { restartCommand, upCommand } = await import( +const { restartCommand, upCommand, runCommand } = await import( "../src/commands/project.ts" ); @@ -125,6 +135,8 @@ afterEach(async () => { upEnvs.length = 0; psEnvs.length = 0; upServiceSelections.length = 0; + runtimeCalls.length = 0; + runExitCode = 0; autoBranch = null; runtimeProjects = []; for (const tempDir of tempDirs) { @@ -606,3 +618,98 @@ async function createProject(opts?: { } return projectRoot; } + +async function createCachedProject(): Promise { + const projectRoot = await createProject({ registryTokenScope: "deps" }); + const composeFile = resolve(projectRoot, ".hack", PROJECT_COMPOSE_FILENAME); + const compose = YAML.parse(await readFile(composeFile, "utf8")) as { + services: Record>; + volumes: Record; + }; + compose.services.api!.volumes = ["dependencies:/app/node_modules"]; + compose.services.deps!.volumes = ["dependencies:/app/node_modules"]; + compose.services.deps!.labels = { + "hack.dependencies.cache-volume": "dependencies", + "hack.dependencies.bootstrap": "true", + "hack.dependencies.lockfiles": "bun.lock", + }; + compose.volumes = { dependencies: {} }; + await writeFile(composeFile, YAML.stringify(compose)); + await writeFile(resolve(projectRoot, "bun.lock"), "first-lock"); + psRows.push( + JSON.stringify({ Service: "api", State: "running", ExitCode: 0 }) + ); + return projectRoot; +} + +for (const operation of [runDetachedUp, runRestart]) { + test(`${operation.name} initializes the selected cache before recreating a consumer`, async () => { + const projectRoot = await createCachedProject(); + expect(await operation({ projectRoot, services: ["api"] })).toBe(0); + expect(runtimeCalls.map((call) => call.kind)).toEqual(["run", "up"]); + const installer = runtimeCalls[0]; + const consumer = runtimeCalls[1]; + expect(installer?.kind).toBe("run"); + if (installer?.kind !== "run" || consumer?.kind !== "up") { + throw new Error("Missing initialization"); + } + expect(installer.opts.service).toBe("deps"); + expect(installer.opts.noDeps).toBe(true); + expect(installer.opts.forwardSignals).toBe(true); + expect(installer.opts.composeFiles).toEqual(consumer.opts.composeFiles); + expect(installer.opts.env).toEqual(consumer.opts.env); + expect(consumer.opts.services).toEqual(["api"]); + const override = installer.opts.composeFiles.find((file) => + file.endsWith("compose.dependencies.override.yml") + ); + expect(override).toBeDefined(); + const firstCache = await readFile(override!, "utf8"); + runtimeCalls.length = 0; + await writeFile(resolve(projectRoot, "bun.lock"), "second-lock"); + expect(await operation({ projectRoot, services: ["api"] })).toBe(0); + expect(runtimeCalls.map((call) => call.kind)).toEqual(["run", "up"]); + expect(await readFile(override!, "utf8")).not.toBe(firstCache); + }); + + test(`${operation.name} leaves consumers untouched when cache initialization fails`, async () => { + const projectRoot = await createCachedProject(); + runExitCode = 42; + expect(await operation({ projectRoot, services: ["api"] })).toBe(42); + expect(runtimeCalls.map((call) => call.kind)).toEqual(["run"]); + }); + + test(`${operation.name} does not bootstrap caches for unrelated services`, async () => { + const projectRoot = await createCachedProject(); + psRows.length = 0; + psRows.push( + JSON.stringify({ Service: "migrate", State: "exited", ExitCode: 0 }) + ); + expect(await operation({ projectRoot, services: ["migrate"] })).toBe(0); + expect(runtimeCalls.map((call) => call.kind)).toEqual(["up"]); + }); +} + +test("run deps resolves the same shared cache as up", async () => { + const projectRoot = await createCachedProject(); + await runDetachedUp({ projectRoot, services: ["api"] }); + const up = runtimeCalls.find((call) => call.kind === "up")!; + runtimeCalls.length = 0; + const result = await runCommand.handler({ + ctx: { cwd: projectRoot, cli: CLI_SPEC }, + args: { + options: { path: projectRoot, env: "base" }, + positionals: { service: "deps", cmd: [] }, + raw: { argv: [], positionals: [] }, + }, + } as unknown as Parameters[0]); + expect(result).toBe(0); + expect(runtimeCalls).toHaveLength(1); + const installer = runtimeCalls[0]!; + expect(installer.kind).toBe("run"); + const dependencyOverrides = (files: readonly string[]) => + files.filter((file) => file.endsWith("compose.dependencies.override.yml")); + expect(dependencyOverrides(installer.opts.composeFiles)).toEqual( + dependencyOverrides(up.opts.composeFiles) + ); + expect(dependencyOverrides(installer.opts.composeFiles)).toHaveLength(1); +}); diff --git a/tests/runtime-backend.test.ts b/tests/runtime-backend.test.ts index 9ee446e2..a331f3e1 100644 --- a/tests/runtime-backend.test.ts +++ b/tests/runtime-backend.test.ts @@ -3,7 +3,11 @@ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const runCalls: string[][] = []; -const runOpts: { stdout?: string }[] = []; +const runOpts: { + stdout?: string; + timeoutMs?: number; + forwardSignals?: boolean; +}[] = []; const execCalls: string[][] = []; const shellMock = await registerScopedModuleMock({ @@ -20,10 +24,18 @@ const shellMock = await registerScopedModuleMock({ }, run: async ( cmd: readonly string[], - opts: { readonly stdout?: string } = {} + opts: { + readonly stdout?: string; + readonly timeoutMs?: number; + readonly forwardSignals?: boolean; + } = {} ) => { runCalls.push([...cmd]); - runOpts.push({ stdout: opts.stdout }); + runOpts.push({ + stdout: opts.stdout, + timeoutMs: opts.timeoutMs, + forwardSignals: opts.forwardSignals, + }); return 0; }, findExecutableInPath: () => "/usr/bin/docker", @@ -380,3 +392,22 @@ test("down routes stdout to stderr when requested (--json purity)", async () => }); expect(runOpts[0]?.stdout).toBe("stderr"); }); + +test("automatic bootstrap preserves output and forwards cancellation to its bounded child", async () => { + const backend = await loadComposeRuntimeBackend(); + await backend.run({ + composeFiles: ["compose.yml"], + cwd: "/tmp", + service: "deps", + cmdArgs: [], + noDeps: true, + timeoutMs: 600_000, + forwardSignals: true, + routeStdoutToStderr: true, + }); + expect(runOpts[0]).toEqual({ + stdout: "stderr", + timeoutMs: 600_000, + forwardSignals: true, + }); +}); From 0dd7eeaa62cdebbfd41f7fdf379e031b18af3f61 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Thu, 17 Sep 2026 19:05:23 -0400 Subject: [PATCH 2/2] fix: scan the checked-out release history in CI --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cef47da..671baea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,22 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Gitleaks scan + - name: Install Gitleaks + id: gitleaks uses: gacts/gitleaks@v1 + with: + version: 8.30.1 + run: 'false' + - name: Scan checked-out commit history + env: + GITLEAKS_BIN: ${{ steps.gitleaks.outputs.gitleaks-bin }} + run: | + "$GITLEAKS_BIN" git . \ + --config .gitleaks.toml \ + --redact \ + --log-opts='--full-history --diff-filter=tuxdb HEAD' \ + --report-format sarif \ + --report-path "$RUNNER_TEMP/gitleaks.sarif" runtime-images: runs-on: blacksmith-4vcpu-ubuntu-2404