Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <service...>` 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 <name>` targets only that explicit branch.

Expand Down
11 changes: 10 additions & 1 deletion src/backends/runtime-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [
Expand Down
137 changes: 116 additions & 21 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});
Expand All @@ -5996,7 +6010,7 @@ async function runUpCommand({
await assertRegistryCredentialsAvailable({
projectRoot: project.projectRoot,
composeFile: project.composeFile,
targetServices,
targetServices: preparedServices,
envByService: envOverrides.preflightEnvByService,
});

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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] : []),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/cli-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 33 additions & 0 deletions src/lib/dependency-cache-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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;
}
Loading
Loading