diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index c56c732b..431e2b63 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). +- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). ## Product boundary @@ -154,6 +154,7 @@ Use `hack` as the primary interface for local-first development. - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). +- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Run commands inside services diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index 2b3c0e13..f0dd0cfa 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -11,7 +11,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). +- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). ## Product boundary diff --git a/AGENTS.md b/AGENTS.md index 7d5863d5..e986574f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,7 +214,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). +- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -336,6 +336,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). +- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Run commands inside services: - One-off: `hack run ` (uses `docker compose run --rm`) diff --git a/CLAUDE.md b/CLAUDE.md index 01e34ba1..9ed6223b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Integration freshness: - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `eef2b0142b95` (version alone is not a freshness guarantee). +- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -206,6 +206,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). +- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Run commands inside services: - One-off: `hack run ` (uses `docker compose run --rm`) diff --git a/docs/cli.md b/docs/cli.md index a0e71e1d..7d1401cf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -158,6 +158,52 @@ Opt out: The primary checkout is unchanged: no `--branch` means the base instance. +### Runtime host metadata + +Containers started by `hack up`, `hack restart`, and `hack run` receive the effective instance and +public-route metadata. This keeps server-generated links, OAuth callbacks, webhooks, and other +browser-facing URLs isolated when the same project runs in multiple worktrees. + +| Variable | Value | +| --- | --- | +| `HACK_BRANCH` | Effective branch slug, or an empty string for the base instance | +| `HACK_COMPOSE_PROJECT` | Effective Compose project name | +| `HACK_DEV_HOST` / `HACK_DEV_URL` | Effective root development host and HTTPS URL | +| `HACK_ALIAS_HOST` / `HACK_ALIAS_URL` | Effective OAuth alias host and URL, when enabled | +| `HACK_SERVICE_NAME` | Current Compose service name | +| `HACK_SERVICE_URL` | Current service's first public URL, when routable | +| `HACK_SERVICE_URLS` | JSON array of every public URL for the current service | +| `HACK_RUNTIME_METADATA` | Versioned JSON document containing the instance hosts and every routable service's URL list | + +Example `HACK_RUNTIME_METADATA` for a branch instance: + +```json +{ + "version": 1, + "branch": "feature-x", + "composeProject": "demo--feature-x", + "hosts": { + "dev": "feature-x.demo.hack", + "alias": "feature-x.demo.hack.gy" + }, + "services": { + "web": { + "urls": ["https://feature-x.demo.hack"] + }, + "api": { + "urls": ["https://api.feature-x.demo.hack"] + } + } +} +``` + +The service map is derived from effective Caddy routes; unroutable services are omitted. Use +Compose DNS names such as `http://api:3000` for container-to-container traffic and runtime metadata +only when a public/browser-reachable URL is required. Explicit project env values retain precedence +over generated metadata for backward compatibility. Existing containers receive the contract after +their next `hack up` or `hack restart`; `hack exec` observes the values already stored in the running +container. + Linked worktrees also inherit the project secret key automatically from the primary checkout through the shared git common dir, so you don't need to copy `.hack.secret.key` by hand. Set `HACK_ENV_SECRET_KEY` for CI or fully detached environments. `hack doctor` flags divergent secret diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index e9069b59..64e62074 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -247,6 +247,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.", "Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`).", "Linked worktrees pick a branch instance automatically (see Linked git worktrees).", + "Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", ], }, { diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index e0a5e4f7..3697a2c5 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "eef2b0142b95"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "64098e5ae1ea"; diff --git a/src/commands/project.ts b/src/commands/project.ts index 8c6079cd..369a0a21 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -90,6 +90,11 @@ import { reassignCollidingPorts, reportInitDiscoveryFindings, } from "../init/validation.ts"; +import { + applyBranchToHost, + applyBranchToHosts, + rewriteCaddyLabelForBranch, +} from "../lib/branch-hosts.ts"; import { resolveEffectiveBranch, touchBranchUsage } from "../lib/branches.ts"; import { type CliResult, @@ -199,6 +204,7 @@ import { preflightRegistryCredentials, RegistryCredentialPreflightError, } from "../lib/registry-credential-preflight.ts"; +import { buildRuntimeHostMetadataOverride } from "../lib/runtime-host-metadata.ts"; import { readRuntimeProjects } from "../lib/runtime-projects.ts"; import { formatSecretStoreDescriptor, @@ -1053,90 +1059,6 @@ function parseLabelEntry(opts: { return { key, value }; } -function rewriteCaddyLabelForBranch(opts: { - readonly value: string; - readonly branch: string; - readonly baseHosts: readonly string[]; -}): { readonly value: string; readonly changed: boolean } { - const parts = opts.value - .split(",") - .map((h) => h.trim()) - .filter((h) => h.length > 0); - - if (parts.length === 0) { - return { value: opts.value, changed: false }; - } - - const out: string[] = []; - const seen = new Set(); - let changed = false; - - for (const host of parts) { - let next = host; - for (const baseHost of opts.baseHosts) { - const rewritten = rewriteHostForBranch({ - host, - branch: opts.branch, - baseHost, - }); - if (rewritten.changed) { - next = rewritten.host; - changed = true; - break; - } - } - - if (seen.has(next)) { - continue; - } - seen.add(next); - out.push(next); - } - - return { value: out.join(", "), changed }; -} - -function rewriteHostForBranch(opts: { - readonly host: string; - readonly branch: string; - readonly baseHost: string; -}): { readonly host: string; readonly changed: boolean } { - if (opts.host === opts.baseHost) { - const next = `${opts.branch}.${opts.baseHost}`; - return { host: next, changed: next !== opts.host }; - } - - const suffix = `.${opts.baseHost}`; - if (!opts.host.endsWith(suffix)) { - return { host: opts.host, changed: false }; - } - - const prefix = opts.host.slice(0, opts.host.length - suffix.length); - if (prefix === opts.branch || prefix.endsWith(`.${opts.branch}`)) { - return { host: opts.host, changed: false }; - } - - return { host: `${prefix}.${opts.branch}.${opts.baseHost}`, changed: true }; -} - -function applyBranchToHost(opts: { - readonly host: string; - readonly branch: string; - readonly baseHosts: readonly string[]; -}): string { - for (const baseHost of opts.baseHosts) { - const rewritten = rewriteHostForBranch({ - host: opts.host, - branch: opts.branch, - baseHost, - }); - if (rewritten.changed) { - return rewritten.host; - } - } - return opts.host; -} - function cleanupYaml(yaml: string): string { return yaml.replaceAll(/: \n/g, ":\n"); } @@ -2941,6 +2863,62 @@ async function resolveBranchComposeFiles(opts: { return [opts.project.composeFile, overridePath]; } +async function resolveRuntimeHostMetadataOverride(opts: { + readonly project: Awaited>; + readonly composeFiles: readonly string[]; + readonly branch: string | null; + readonly devHost: string; + readonly aliasHost: string | null; + readonly composeProject: string; +}): Promise { + const composeYamls = ( + await Promise.all( + opts.composeFiles.map(async (path) => await readTextFile(path)) + ) + ).filter((text): text is string => typeof text === "string"); + if (composeYamls.length === 0) { + return null; + } + const override = buildRuntimeHostMetadataOverride({ + composeYamls, + branch: opts.branch, + devHost: opts.devHost, + aliasHost: opts.aliasHost, + composeProject: opts.composeProject, + }); + if (!override) { + return null; + } + + await ensureHackDirGitignore({ projectDir: opts.project.projectDir }); + const overrideDir = resolve( + opts.project.projectDir, + opts.branch ? ".branch" : ".internal" + ); + await ensureDir(overrideDir); + const overridePath = resolve( + overrideDir, + opts.branch + ? `compose.${opts.branch}.runtime.override.yml` + : "compose.runtime.override.yml" + ); + await writeTextFileIfChanged(overridePath, override); + return overridePath; +} + +async function resolveRuntimeDevHost(opts: { + readonly project: Awaited>; + readonly branch: string | null; +}): Promise { + if (opts.branch) { + return await resolveBranchDevHost({ project: opts.project }); + } + return ( + (await readProjectDevHost(opts.project)) ?? + `${defaultProjectSlugFromPath(opts.project.projectRoot)}.${DEFAULT_PROJECT_TLD}` + ); +} + const INTERNAL_CA_CONTAINER_DIR = "/etc/hack/ca"; const INTERNAL_CA_CONTAINER_PATH = `${INTERNAL_CA_CONTAINER_DIR}/caddy-local-authority.crt`; const INTERNAL_TRUST_BUNDLE_CONTAINER_PATH = `${INTERNAL_CA_CONTAINER_DIR}/trust-bundle.pem`; @@ -3443,28 +3421,6 @@ function extractCaddyHosts(value: string): readonly string[] { return out; } -function applyBranchToHosts(opts: { - readonly hosts: readonly string[]; - readonly branch: string; - readonly baseHosts: readonly string[]; -}): readonly string[] { - const out: string[] = []; - const seen = new Set(); - for (const host of opts.hosts) { - const next = applyBranchToHost({ - host, - branch: opts.branch, - baseHosts: opts.baseHosts, - }); - if (seen.has(next)) { - continue; - } - seen.add(next); - out.push(next); - } - return out; -} - function buildExtraHostsMap(opts: { readonly hosts: readonly string[]; readonly ip: string; @@ -5939,9 +5895,8 @@ async function runUpCommand({ projectName, branch, }); - const devHost = branch ? await resolveBranchDevHost({ project }) : null; - const aliasHost = - branch && devHost ? resolveBranchAliasHost({ devHost, cfg }) : null; + const devHost = await resolveRuntimeDevHost({ project, branch }); + const aliasHost = resolveBranchAliasHost({ devHost, cfg }); const internalSettings = resolveInternalSettings(cfg); await maybePromptToStartGlobal({ internal: internalSettings }); const internalOverride = await resolveInternalComposeOverride({ @@ -5967,6 +5922,14 @@ async function runUpCommand({ const composeFilesWithRuntimeOverrides = dependencyCache.overridePath ? [...composeFilesWithInternal, dependencyCache.overridePath] : composeFilesWithInternal; + const runtimeMetadataOverride = await resolveRuntimeHostMetadataOverride({ + project, + composeFiles: composeFilesWithRuntimeOverrides, + branch, + devHost, + aliasHost, + composeProject: composeProjectName ?? baseProjectName, + }); const allServiceNames = await readComposeServiceNames(project.composeFile); const requestedServices = resolveRequestedComposeServices({ @@ -5984,6 +5947,7 @@ async function runUpCommand({ }); const composeFilesWithEnv = [ ...composeFilesWithRuntimeOverrides, + ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), ...envOverrides.composeFiles, ]; @@ -6515,6 +6479,7 @@ async function runRestartDownPhase(opts: { async function runRestartUpPhase(opts: { readonly project: Awaited>; readonly cfg: Awaited>; + readonly baseProjectName: string; readonly projectName: string; readonly composeProjectName: string | null; readonly lifecycleComposeProject: string; @@ -6526,13 +6491,11 @@ async function runRestartUpPhase(opts: { }): Promise { await maybeSyncOauthAliasesInCompose({ project: opts.project }); - const devHost = opts.branch - ? await resolveBranchDevHost({ project: opts.project }) - : null; - const aliasHost = - opts.branch && devHost - ? resolveBranchAliasHost({ devHost, cfg: opts.cfg }) - : null; + const devHost = await resolveRuntimeDevHost({ + project: opts.project, + branch: opts.branch, + }); + const aliasHost = resolveBranchAliasHost({ devHost, cfg: opts.cfg }); const internalOverride = await resolveInternalComposeOverride({ project: opts.project, cfg: opts.cfg, @@ -6561,6 +6524,14 @@ async function runRestartUpPhase(opts: { const composeFilesWithRuntimeOverrides = dependencyCache.overridePath ? [...composeFilesWithInternal, dependencyCache.overridePath] : composeFilesWithInternal; + const runtimeMetadataOverride = await resolveRuntimeHostMetadataOverride({ + project: opts.project, + composeFiles: composeFilesWithRuntimeOverrides, + branch: opts.branch, + devHost, + aliasHost, + composeProject: opts.composeProjectName ?? opts.baseProjectName, + }); const targetServices = await readComposeServiceNames( opts.project.composeFile @@ -6580,6 +6551,7 @@ async function runRestartUpPhase(opts: { }); const composeFilesWithEnv = [ ...composeFilesWithRuntimeOverrides, + ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), ...envOverrides.composeFiles, ]; @@ -6709,6 +6681,7 @@ type TargetedServiceRestartResult = async function runTargetedServiceRestart(opts: { readonly project: Awaited>; readonly cfg: Awaited>; + readonly baseProjectName: string; readonly projectName: string; readonly composeProjectName: string | null; readonly profiles: readonly string[]; @@ -6719,13 +6692,11 @@ async function runTargetedServiceRestart(opts: { readonly routeStdoutToStderr: boolean; }): Promise { await maybeSyncOauthAliasesInCompose({ project: opts.project }); - const devHost = opts.branch - ? await resolveBranchDevHost({ project: opts.project }) - : null; - const aliasHost = - opts.branch && devHost - ? resolveBranchAliasHost({ devHost, cfg: opts.cfg }) - : null; + const devHost = await resolveRuntimeDevHost({ + project: opts.project, + branch: opts.branch, + }); + const aliasHost = resolveBranchAliasHost({ devHost, cfg: opts.cfg }); const internalOverride = await resolveInternalComposeOverride({ project: opts.project, cfg: opts.cfg, @@ -6761,11 +6732,23 @@ async function runTargetedServiceRestart(opts: { projectName: opts.projectName, composeFile: opts.project.composeFile, }); + const composeFilesWithRuntimeOverrides = [ + ...composeFiles, + ...(internalOverride ? [internalOverride] : []), + ...(dependencyCache.overridePath ? [dependencyCache.overridePath] : []), + ]; + const runtimeMetadataOverride = await resolveRuntimeHostMetadataOverride({ + project: opts.project, + composeFiles: composeFilesWithRuntimeOverrides, + branch: opts.branch, + devHost, + aliasHost, + composeProject: opts.composeProjectName ?? opts.baseProjectName, + }); const code = await composeRuntimeBackend.up({ composeFiles: [ - ...composeFiles, - ...(internalOverride ? [internalOverride] : []), - ...(dependencyCache.overridePath ? [dependencyCache.overridePath] : []), + ...composeFilesWithRuntimeOverrides, + ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), ...envOverrides.composeFiles, ], composeProject: opts.composeProjectName, @@ -6941,6 +6924,7 @@ async function runRestartCommand({ const result = await runTargetedServiceRestart({ project, cfg, + baseProjectName, projectName, composeProjectName, profiles, @@ -7036,6 +7020,7 @@ async function runRestartCommand({ const upCode = await runRestartUpPhase({ project, cfg, + baseProjectName, projectName, composeProjectName, lifecycleComposeProject, @@ -7416,9 +7401,8 @@ async function handleRun({ const baseProjectName = await resolveComposeProjectName({ project, cfg }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; - const devHost = branch ? await resolveBranchDevHost({ project }) : null; - const aliasHost = - branch && devHost ? resolveBranchAliasHost({ devHost, cfg }) : null; + const devHost = await resolveRuntimeDevHost({ project, branch }); + const aliasHost = resolveBranchAliasHost({ devHost, cfg }); const internalOverride = await resolveInternalComposeOverride({ project, cfg, @@ -7426,9 +7410,20 @@ async function handleRun({ devHost, aliasHost, }); - const composeFiles = internalOverride - ? [project.composeFile, internalOverride] + const composeFiles = branch + ? await resolveBranchComposeFiles({ project, branch, devHost, aliasHost }) : [project.composeFile]; + const composeFilesWithInternal = internalOverride + ? [...composeFiles, internalOverride] + : composeFiles; + const runtimeMetadataOverride = await resolveRuntimeHostMetadataOverride({ + project, + composeFiles: composeFilesWithInternal, + branch, + devHost, + aliasHost, + composeProject: composeProjectName ?? baseProjectName, + }); const projectName = sanitizeProjectSlug(baseProjectName); const allServiceNames = await readComposeServiceNames(project.composeFile); @@ -7439,7 +7434,11 @@ async function handleRun({ allServiceNames, envName, }); - const composeFilesWithEnv = [...composeFiles, ...envOverrides.composeFiles]; + const composeFilesWithEnv = [ + ...composeFilesWithInternal, + ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), + ...envOverrides.composeFiles, + ]; const stackIsRunning = await resolveCanSkipRunDependencies({ composeFiles: composeFilesWithEnv, composeProjectKey: composeProjectName ?? baseProjectName, @@ -7508,9 +7507,8 @@ async function handleExec({ const baseProjectName = await resolveComposeProjectName({ project, cfg }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; - const devHost = branch ? await resolveBranchDevHost({ project }) : null; - const aliasHost = - branch && devHost ? resolveBranchAliasHost({ devHost, cfg }) : null; + const devHost = await resolveRuntimeDevHost({ project, branch }); + const aliasHost = resolveBranchAliasHost({ devHost, cfg }); const internalOverride = await resolveInternalComposeOverride({ project, cfg, @@ -7518,9 +7516,20 @@ async function handleExec({ devHost, aliasHost, }); - const composeFiles = internalOverride - ? [project.composeFile, internalOverride] + const composeFiles = branch + ? await resolveBranchComposeFiles({ project, branch, devHost, aliasHost }) : [project.composeFile]; + const composeFilesWithInternal = internalOverride + ? [...composeFiles, internalOverride] + : composeFiles; + const runtimeMetadataOverride = await resolveRuntimeHostMetadataOverride({ + project, + composeFiles: composeFilesWithInternal, + branch, + devHost, + aliasHost, + composeProject: composeProjectName ?? baseProjectName, + }); const projectName = sanitizeProjectSlug(baseProjectName); const lifecycleComposeProject = resolveLifecycleComposeProjectName({ @@ -7535,7 +7544,11 @@ async function handleExec({ allServiceNames, envName, }); - const composeFilesWithEnv = [...composeFiles, ...envOverrides.composeFiles]; + const composeFilesWithEnv = [ + ...composeFilesWithInternal, + ...(runtimeMetadataOverride ? [runtimeMetadataOverride] : []), + ...envOverrides.composeFiles, + ]; const execReady = await resolveExecTargetReady({ composeFiles: composeFilesWithEnv, composeProjectKey: lifecycleComposeProject, diff --git a/src/lib/branch-hosts.ts b/src/lib/branch-hosts.ts new file mode 100644 index 00000000..032a4c21 --- /dev/null +++ b/src/lib/branch-hosts.ts @@ -0,0 +1,98 @@ +export function rewriteCaddyLabelForBranch(opts: { + readonly value: string; + readonly branch: string; + readonly baseHosts: readonly string[]; +}): { readonly value: string; readonly changed: boolean } { + const parts = opts.value + .split(",") + .map((host) => host.trim()) + .filter((host) => host.length > 0); + + if (parts.length === 0) { + return { value: opts.value, changed: false }; + } + + const out: string[] = []; + const seen = new Set(); + let changed = false; + + for (const host of parts) { + const next = applyBranchToHost({ + host, + branch: opts.branch, + baseHosts: opts.baseHosts, + }); + if (next !== host) { + changed = true; + } + if (seen.has(next)) { + continue; + } + seen.add(next); + out.push(next); + } + + return { value: out.join(", "), changed }; +} + +export function applyBranchToHost(opts: { + readonly host: string; + readonly branch: string; + readonly baseHosts: readonly string[]; +}): string { + for (const baseHost of opts.baseHosts) { + const rewritten = rewriteHostForBranch({ + host: opts.host, + branch: opts.branch, + baseHost, + }); + if (rewritten.changed) { + return rewritten.host; + } + } + return opts.host; +} + +export function applyBranchToHosts(opts: { + readonly hosts: readonly string[]; + readonly branch: string; + readonly baseHosts: readonly string[]; +}): readonly string[] { + const out: string[] = []; + const seen = new Set(); + for (const host of opts.hosts) { + const next = applyBranchToHost({ + host, + branch: opts.branch, + baseHosts: opts.baseHosts, + }); + if (seen.has(next)) { + continue; + } + seen.add(next); + out.push(next); + } + return out; +} + +function rewriteHostForBranch(opts: { + readonly host: string; + readonly branch: string; + readonly baseHost: string; +}): { readonly host: string; readonly changed: boolean } { + if (opts.host === opts.baseHost) { + const next = `${opts.branch}.${opts.baseHost}`; + return { host: next, changed: next !== opts.host }; + } + + const suffix = `.${opts.baseHost}`; + if (!opts.host.endsWith(suffix)) { + return { host: opts.host, changed: false }; + } + + const prefix = opts.host.slice(0, opts.host.length - suffix.length); + if (prefix.endsWith(`.${opts.branch}`)) { + return { host: opts.host, changed: false }; + } + return { host: `${prefix}.${opts.branch}.${opts.baseHost}`, changed: true }; +} diff --git a/src/lib/runtime-host-metadata.ts b/src/lib/runtime-host-metadata.ts new file mode 100644 index 00000000..7badd1ad --- /dev/null +++ b/src/lib/runtime-host-metadata.ts @@ -0,0 +1,288 @@ +import { YAML } from "bun"; + +import { applyBranchToHost } from "./branch-hosts.ts"; +import { isRecord } from "./guards.ts"; + +export const RUNTIME_HOST_METADATA_KEYS = { + branch: "HACK_BRANCH", + devHost: "HACK_DEV_HOST", + devUrl: "HACK_DEV_URL", + aliasHost: "HACK_ALIAS_HOST", + aliasUrl: "HACK_ALIAS_URL", + composeProject: "HACK_COMPOSE_PROJECT", + runtimeMetadata: "HACK_RUNTIME_METADATA", + serviceName: "HACK_SERVICE_NAME", + serviceUrl: "HACK_SERVICE_URL", + serviceUrls: "HACK_SERVICE_URLS", +} as const; + +export type RuntimeHostMetadata = { + readonly version: 1; + readonly branch: string | null; + readonly composeProject: string; + readonly hosts: { + readonly dev: string; + readonly alias: string | null; + }; + readonly services: Readonly< + Record + >; +}; + +/** + * Builds the generated Compose fragment that exposes non-secret runtime + * identity to every service. Keys already declared by a service are omitted; + * later Hack env overrides retain their normal higher precedence. + */ +export function buildRuntimeHostMetadataOverride(opts: { + readonly composeYamls: readonly string[]; + readonly branch: string | null; + readonly devHost: string; + readonly aliasHost: string | null; + readonly composeProject: string; +}): string | null { + const effectiveServices = readEffectiveServices({ + composeYamls: opts.composeYamls, + }); + if (effectiveServices.size === 0) { + return null; + } + + const baseHosts = [opts.devHost, opts.aliasHost].filter( + (host): host is string => typeof host === "string" && host.length > 0 + ); + const effectiveDevHost = applyBranchIfNeeded({ + host: opts.devHost, + branch: opts.branch, + baseHosts, + }); + const effectiveAliasHost = opts.aliasHost + ? applyBranchIfNeeded({ + host: opts.aliasHost, + branch: opts.branch, + baseHosts, + }) + : null; + const publicServices: Record = + {}; + for (const [serviceName, service] of effectiveServices) { + const urls = resolveServiceUrls({ + caddy: service.caddy, + }); + if (urls.length > 0) { + publicServices[serviceName] = { urls }; + } + } + const runtimeMetadata: RuntimeHostMetadata = { + version: 1, + branch: opts.branch, + composeProject: opts.composeProject, + hosts: { + dev: effectiveDevHost, + alias: effectiveAliasHost, + }, + services: publicServices, + }; + const sharedMetadata: Record = { + [RUNTIME_HOST_METADATA_KEYS.branch]: opts.branch ?? "", + [RUNTIME_HOST_METADATA_KEYS.devHost]: effectiveDevHost, + [RUNTIME_HOST_METADATA_KEYS.devUrl]: `https://${effectiveDevHost}`, + [RUNTIME_HOST_METADATA_KEYS.composeProject]: opts.composeProject, + [RUNTIME_HOST_METADATA_KEYS.runtimeMetadata]: + JSON.stringify(runtimeMetadata), + ...(effectiveAliasHost + ? { + [RUNTIME_HOST_METADATA_KEYS.aliasHost]: effectiveAliasHost, + [RUNTIME_HOST_METADATA_KEYS.aliasUrl]: `https://${effectiveAliasHost}`, + } + : {}), + }; + const services: Record }> = {}; + + for (const [serviceName, service] of effectiveServices) { + const urls = publicServices[serviceName]?.urls ?? []; + const metadata: Record = { + ...sharedMetadata, + [RUNTIME_HOST_METADATA_KEYS.serviceName]: serviceName, + [RUNTIME_HOST_METADATA_KEYS.serviceUrls]: JSON.stringify(urls), + ...(urls[0] ? { [RUNTIME_HOST_METADATA_KEYS.serviceUrl]: urls[0] } : {}), + }; + const environment = Object.fromEntries( + Object.entries(metadata).filter( + ([key]) => !service.declaredEnvironmentKeys.has(key) + ) + ); + if (Object.keys(environment).length > 0) { + services[serviceName] = { environment }; + } + } + + if (Object.keys(services).length === 0) { + return null; + } + return ensureTrailingNewline( + cleanupYaml(YAML.stringify({ services }, null, 2)) + ); +} + +function parseComposeYaml(opts: { + readonly text: string; +}): Record | null { + try { + const parsed: unknown = YAML.parse(opts.text); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +type EffectiveService = { + caddy: string | null; + readonly declaredEnvironmentKeys: Set; +}; + +function readEffectiveServices(opts: { + readonly composeYamls: readonly string[]; +}): ReadonlyMap { + const services = new Map(); + for (const text of opts.composeYamls) { + const parsed = parseComposeYaml({ text }); + if (!(parsed && isRecord(parsed.services))) { + continue; + } + for (const [serviceName, serviceRaw] of Object.entries(parsed.services)) { + if (!isRecord(serviceRaw)) { + continue; + } + const current = services.get(serviceName) ?? { + caddy: null, + declaredEnvironmentKeys: new Set(), + }; + const caddy = readCaddyLabel({ labels: serviceRaw.labels }); + if (caddy) { + current.caddy = caddy; + } + for (const key of readDeclaredEnvironmentKeys({ + environment: serviceRaw.environment, + })) { + current.declaredEnvironmentKeys.add(key); + } + services.set(serviceName, current); + } + } + return services; +} + +function applyBranchIfNeeded(opts: { + readonly host: string; + readonly branch: string | null; + readonly baseHosts: readonly string[]; +}): string { + return opts.branch + ? applyBranchToHost({ + host: opts.host, + branch: opts.branch, + baseHosts: opts.baseHosts, + }) + : opts.host; +} + +function resolveServiceUrls(opts: { + readonly caddy: string | null; +}): readonly string[] { + if (!opts.caddy) { + return []; + } + + const urls: string[] = []; + const seen = new Set(); + for (const host of extractCaddyHosts({ value: opts.caddy })) { + const url = `https://${host}`; + if (!seen.has(url)) { + seen.add(url); + urls.push(url); + } + } + return urls; +} + +function readCaddyLabel(opts: { readonly labels: unknown }): string | null { + if (isRecord(opts.labels)) { + const value = opts.labels.caddy; + return typeof value === "string" && value.trim().length > 0 ? value : null; + } + if (!Array.isArray(opts.labels)) { + return null; + } + for (const entry of opts.labels) { + if (typeof entry !== "string") { + continue; + } + const separator = entry.indexOf("="); + if (separator > 0 && entry.slice(0, separator).trim() === "caddy") { + return entry.slice(separator + 1).trim(); + } + } + return null; +} + +function extractCaddyHosts(opts: { + readonly value: string; +}): readonly string[] { + const hosts: string[] = []; + for (const part of opts.value.split(",")) { + let host = part.trim(); + if (host.startsWith("http://")) { + host = host.slice("http://".length); + } else if (host.startsWith("https://")) { + host = host.slice("https://".length); + } + const slashIndex = host.indexOf("/"); + if (slashIndex !== -1) { + host = host.slice(0, slashIndex); + } + if ( + host.length === 0 || + host.includes("*") || + host.includes("{") || + host.includes("}") || + host.includes("$") || + host.includes(":") + ) { + continue; + } + hosts.push(host); + } + return hosts; +} + +function readDeclaredEnvironmentKeys(opts: { + readonly environment: unknown; +}): Set { + if (isRecord(opts.environment)) { + return new Set(Object.keys(opts.environment)); + } + if (!Array.isArray(opts.environment)) { + return new Set(); + } + const keys = new Set(); + for (const entry of opts.environment) { + if (typeof entry !== "string") { + continue; + } + const separator = entry.indexOf("="); + const key = (separator === -1 ? entry : entry.slice(0, separator)).trim(); + if (key.length > 0) { + keys.add(key); + } + } + return keys; +} + +function cleanupYaml(yaml: string): string { + return yaml.replaceAll(/: \n/g, ":\n"); +} + +function ensureTrailingNewline(text: string): string { + return text.endsWith("\n") ? text : `${text}\n`; +} diff --git a/tests/branch-hosts.test.ts b/tests/branch-hosts.test.ts new file mode 100644 index 00000000..4e2c141f --- /dev/null +++ b/tests/branch-hosts.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; + +import { + applyBranchToHost, + applyBranchToHosts, + rewriteCaddyLabelForBranch, +} from "../src/lib/branch-hosts.ts"; + +const BASE_HOSTS = ["demo.hack", "demo.hack.gy"]; + +test("branch host rewriting handles root, matching service, alias, and foreign hosts", () => { + expect( + applyBranchToHost({ + host: "demo.hack", + branch: "feature-x", + baseHosts: BASE_HOSTS, + }) + ).toBe("feature-x.demo.hack"); + expect( + applyBranchToHost({ + host: "api.demo.hack.gy", + branch: "feature-x", + baseHosts: BASE_HOSTS, + }) + ).toBe("api.feature-x.demo.hack.gy"); + expect( + applyBranchToHost({ + host: "api.demo.hack", + branch: "api", + baseHosts: BASE_HOSTS, + }) + ).toBe("api.api.demo.hack"); + expect( + applyBranchToHost({ + host: "api.feature-x.demo.hack", + branch: "feature-x", + baseHosts: BASE_HOSTS, + }) + ).toBe("api.feature-x.demo.hack"); + expect( + applyBranchToHost({ + host: "external.example.com", + branch: "feature-x", + baseHosts: BASE_HOSTS, + }) + ).toBe("external.example.com"); +}); + +test("branch host collections preserve order and remove duplicates", () => { + expect( + applyBranchToHosts({ + hosts: ["demo.hack", "demo.hack", "api.demo.hack"], + branch: "feature-x", + baseHosts: BASE_HOSTS, + }) + ).toEqual(["feature-x.demo.hack", "api.feature-x.demo.hack"]); + + expect( + rewriteCaddyLabelForBranch({ + value: "demo.hack, demo.hack, api.demo.hack", + branch: "feature-x", + baseHosts: BASE_HOSTS, + }) + ).toEqual({ + value: "feature-x.demo.hack, api.feature-x.demo.hack", + changed: true, + }); +}); diff --git a/tests/e2e/scenarios/worktree-parallel-up.ts b/tests/e2e/scenarios/worktree-parallel-up.ts index 157b8912..74de91a1 100644 --- a/tests/e2e/scenarios/worktree-parallel-up.ts +++ b/tests/e2e/scenarios/worktree-parallel-up.ts @@ -9,6 +9,14 @@ import { import { downBestEffort, requireDockerPreconditions } from "./docker-shared.ts"; type OpenPayload = { readonly url?: string }; +type RuntimeMetadataPayload = { + readonly branch?: string | null; + readonly composeProject?: string; + readonly hosts?: { readonly dev?: string; readonly alias?: string | null }; + readonly services?: Readonly< + Record + >; +}; const BRANCH = "e2e-parallel"; const UP_TIMEOUT_MS = 420_000; @@ -71,6 +79,36 @@ export const worktreeParallelUpScenario: Scenario = { message: `worktree route should carry the branch slug "${BRANCH}", got "${worktreeUrl}"`, }); + const primaryMetadata = await readRuntimeMetadata({ + ctx, + cwd: fixture.root, + }); + const branchMetadata = await readRuntimeMetadata({ + ctx, + cwd: worktreePath, + }); + expect({ + that: + primaryMetadata?.branch === null && + primaryMetadata.hosts?.dev === fixture.devHost && + primaryMetadata.services?.api?.urls?.includes( + `https://api.${fixture.devHost}` + ) === true, + message: "primary containers should receive base-instance runtime URLs", + }); + expect({ + that: + branchMetadata?.branch === BRANCH && + branchMetadata.composeProject === `${fixture.name}--${BRANCH}` && + branchMetadata.hosts?.dev === `${BRANCH}.${fixture.devHost}` && + branchMetadata.services?.web?.urls?.includes(worktreeUrl) === true && + branchMetadata.services?.api?.urls?.includes( + `https://api.${BRANCH}.${fixture.devHost}` + ) === true, + message: + "branch containers should receive only branch-qualified public runtime URLs", + }); + const composeLs = await runCommand({ argv: ["docker", "compose", "ls", "--format", "json"], cwd: fixture.root, @@ -134,6 +172,19 @@ export const worktreeParallelUpScenario: Scenario = { "primary restart changed the linked-worktree branch containers", result: restartPrimary, }); + const primaryMetadataAfterRestart = await readRuntimeMetadata({ + ctx, + cwd: fixture.root, + }); + expect({ + that: + primaryMetadataAfterRestart?.composeProject === fixture.name && + primaryMetadataAfterRestart.services?.api?.urls?.includes( + `https://api.${fixture.devHost}` + ) === true, + message: "primary restart should preserve base-instance runtime URLs", + result: restartPrimary, + }); const downWorktree = await ctx.cli({ args: ["down"], @@ -201,3 +252,19 @@ async function resolveOpenUrl(opts: { }); return extractJsonObject({ text: result.stdout })?.url ?? ""; } + +async function readRuntimeMetadata(opts: { + readonly ctx: Parameters[0]; + readonly cwd: string; +}): Promise { + const result = await opts.ctx.cli({ + args: ["exec", "web", "--", "printenv", "HACK_RUNTIME_METADATA"], + cwd: opts.cwd, + }); + expectExit({ + result, + codes: [0], + message: `hack exec should expose runtime metadata in ${opts.cwd}`, + }); + return extractJsonObject({ text: result.stdout }); +} diff --git a/tests/project-run-command.test.ts b/tests/project-run-command.test.ts index 39b3bd00..841187b1 100644 --- a/tests/project-run-command.test.ts +++ b/tests/project-run-command.test.ts @@ -147,6 +147,17 @@ test("run applies modern env overlays to service-specific compose overrides", as expect(call?.noDeps).toBe(false); expect(call?.composeFiles.length ?? 0).toBeGreaterThanOrEqual(2); + const runtimeOverridePath = call?.composeFiles.find((filePath) => + filePath.endsWith("compose.runtime.override.yml") + ); + expect(runtimeOverridePath).toBeDefined(); + if (!runtimeOverridePath) { + throw new Error("Missing runtime metadata compose override."); + } + const runtimeOverrideText = await readFile(runtimeOverridePath, "utf8"); + expect(runtimeOverrideText).toContain("HACK_RUNTIME_METADATA"); + expect(runtimeOverrideText).toContain("https://project-run-env.hack"); + const overridePath = call?.composeFiles.find((filePath) => filePath.endsWith("compose.env.override.yml") ); diff --git a/tests/run-exec-branch-default.test.ts b/tests/run-exec-branch-default.test.ts index 1dd106b8..54d60302 100644 --- a/tests/run-exec-branch-default.test.ts +++ b/tests/run-exec-branch-default.test.ts @@ -89,7 +89,21 @@ async function createWorktreeFixture(): Promise<{ ); await writeFile( join(hackDir, "docker-compose.yml"), - ["services:", " api:", " image: alpine:3.20", ""].join("\n") + [ + "services:", + " api:", + " image: alpine:3.20", + " labels:", + ' caddy: "api.runbranch.hack"', + ' caddy.reverse_proxy: "{{upstreams 3000}}"', + " caddy.tls: internal", + " networks:", + " - hack-dev", + "networks:", + " hack-dev:", + " external: true", + "", + ].join("\n") ); await writeFile(join(primaryRoot, "README.md"), "# fixture\n"); @@ -137,6 +151,21 @@ test("hack run in a linked worktree targets the branch compose project by defaul expect(result.exitCode).toBe(0); const log = await readFile(fixture.dockerLogPath, "utf8"); expect(log).toContain("-p runbranch--feature-run-default"); + expect(log).toContain("compose.feature-run-default.override.yml"); + expect(log).toContain("compose.feature-run-default.runtime.override.yml"); + const runtimeOverride = await readFile( + join( + fixture.worktreeRoot, + ".hack", + ".branch", + "compose.feature-run-default.runtime.override.yml" + ), + "utf8" + ); + expect(runtimeOverride).toContain("HACK_RUNTIME_METADATA"); + expect(runtimeOverride).toContain( + "https://feature-run-default.runbranch.hack" + ); expect(result.stderr).toContain('branch instance "feature-run-default"'); }); @@ -178,6 +207,8 @@ test("hack exec in a linked worktree targets the branch compose project by defau const log = await readFile(fixture.dockerLogPath, "utf8").catch(() => ""); const combined = `${log}\n${result.stdout}\n${result.stderr}`; expect(combined).toContain('branch instance "feature-run-default"'); + expect(log).toContain("compose.feature-run-default.override.yml"); + expect(log).toContain("compose.feature-run-default.runtime.override.yml"); }); test("hack run in a detached linked worktree refuses to target the base compose project", async () => { diff --git a/tests/runtime-host-metadata.test.ts b/tests/runtime-host-metadata.test.ts new file mode 100644 index 00000000..b125a498 --- /dev/null +++ b/tests/runtime-host-metadata.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from "bun:test"; +import { YAML } from "bun"; + +import { isRecord } from "../src/lib/guards.ts"; +import { + buildRuntimeHostMetadataOverride, + type RuntimeHostMetadata, +} from "../src/lib/runtime-host-metadata.ts"; + +const BASE_COMPOSE = [ + "services:", + " web:", + " image: app", + " environment:", + ' HACK_SERVICE_URL: "user-owned"', + " labels:", + ' caddy: "demo.hack, demo.hack.gy"', + " api:", + " image: app", + " worker:", + " image: app", + "", +].join("\n"); + +const ROUTING_OVERRIDE = [ + "services:", + " api:", + " labels:", + ' - "caddy=api.demo.hack, api.demo.hack.gy"', + "", +].join("\n"); + +const BRANCH_ROUTING_OVERRIDE = [ + "services:", + " web:", + " labels:", + ' caddy: "feature-x.demo.hack, feature-x.demo.hack.gy"', + " api:", + " labels:", + ' caddy: "api.feature-x.demo.hack, api.feature-x.demo.hack.gy"', + "", +].join("\n"); + +test("runtime metadata exposes the effective public service map to every service", () => { + const override = buildRuntimeHostMetadataOverride({ + composeYamls: [BASE_COMPOSE, ROUTING_OVERRIDE], + branch: null, + devHost: "demo.hack", + aliasHost: "demo.hack.gy", + composeProject: "demo", + }); + expect(override).not.toBeNull(); + + const web = readServiceEnvironment({ override, service: "web" }); + const worker = readServiceEnvironment({ override, service: "worker" }); + const metadata = readRuntimeMetadata({ environment: worker }); + + expect(metadata).toEqual({ + version: 1, + branch: null, + composeProject: "demo", + hosts: { dev: "demo.hack", alias: "demo.hack.gy" }, + services: { + web: { urls: ["https://demo.hack", "https://demo.hack.gy"] }, + api: { + urls: ["https://api.demo.hack", "https://api.demo.hack.gy"], + }, + }, + }); + expect(web.HACK_DEV_URL).toBe("https://demo.hack"); + expect(web.HACK_ALIAS_URL).toBe("https://demo.hack.gy"); + expect(web.HACK_SERVICE_NAME).toBe("web"); + expect(web.HACK_SERVICE_URL).toBeUndefined(); + expect(JSON.parse(web.HACK_SERVICE_URLS ?? "null")).toEqual([ + "https://demo.hack", + "https://demo.hack.gy", + ]); + expect(worker.HACK_SERVICE_URL).toBeUndefined(); + expect(worker.HACK_SERVICE_URLS).toBe("[]"); +}); + +test("runtime metadata uses every effective public URL for a branch instance", () => { + const override = buildRuntimeHostMetadataOverride({ + composeYamls: [BASE_COMPOSE, BRANCH_ROUTING_OVERRIDE], + branch: "feature-x", + devHost: "demo.hack", + aliasHost: "demo.hack.gy", + composeProject: "demo--feature-x", + }); + const api = readServiceEnvironment({ override, service: "api" }); + const metadata = readRuntimeMetadata({ environment: api }); + + expect(api).toMatchObject({ + HACK_BRANCH: "feature-x", + HACK_COMPOSE_PROJECT: "demo--feature-x", + HACK_DEV_HOST: "feature-x.demo.hack", + HACK_DEV_URL: "https://feature-x.demo.hack", + HACK_ALIAS_HOST: "feature-x.demo.hack.gy", + HACK_ALIAS_URL: "https://feature-x.demo.hack.gy", + HACK_SERVICE_NAME: "api", + HACK_SERVICE_URL: "https://api.feature-x.demo.hack", + }); + expect(metadata.services.api?.urls).toEqual([ + "https://api.feature-x.demo.hack", + "https://api.feature-x.demo.hack.gy", + ]); + expect(metadata.services.web?.urls).toEqual([ + "https://feature-x.demo.hack", + "https://feature-x.demo.hack.gy", + ]); +}); + +function readServiceEnvironment(opts: { + readonly override: string | null; + readonly service: string; +}): Record { + if (!opts.override) { + throw new Error("Expected a runtime metadata override"); + } + const parsed: unknown = YAML.parse(opts.override); + if (!(isRecord(parsed) && isRecord(parsed.services))) { + throw new Error("Runtime metadata override has no services"); + } + const service = parsed.services[opts.service]; + if (!(isRecord(service) && isRecord(service.environment))) { + throw new Error(`Runtime metadata override has no ${opts.service} env`); + } + return Object.fromEntries( + Object.entries(service.environment).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ); +} + +function readRuntimeMetadata(opts: { + readonly environment: Readonly>; +}): RuntimeHostMetadata { + const raw = opts.environment.HACK_RUNTIME_METADATA; + if (!raw) { + throw new Error("Missing HACK_RUNTIME_METADATA"); + } + return JSON.parse(raw) as RuntimeHostMetadata; +}