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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ jobs:
run: HACK_TEST_COMPOSE_CONFIG=1 bun test tests/native-compose-renderer-config.test.ts --test-name-pattern '^one authored bridge keeps internal policy and static aliases through compiler and Compose normalization$'
- name: Run local and Docker E2E
env:
HACK_E2E_PROCESS_POLICY_REFUSAL_CAPSULE: "1"
HACK_E2E_CLI_BIN: ${{ github.workspace }}/dist/hack
run: |
HACK_E2E_DOCKER_HOST="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}"
Expand Down
80 changes: 80 additions & 0 deletions docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,86 @@ Use the same prerequisites and flags as above with
`--only=native-compose-adoption-managed-worktrees`. Registration and synthetic
fixture controls do not establish a live pass.

## Retained routing contract under implementation

The separate private version 14 mapper admits literal legacy `dev_host`, its
already configured OAuth alias and `open.prefer`, together with closed static
Caddy HTTP upstream labels. It pins full HTTPS origins rather than deriving a
new host from the project name or a global domain. Routed services must configure
exactly the existing `hack-dev` attachment and the project default bridge; other
services retain only the default bridge and existing local named storage.

Ordinary import preview remains outside this private family. The retained routing
owner binds the original resources and ingress incarnations, reserves the exact
hostnames, and verifies current proxy dispatch before clearing a startup receipt.
Saved reads preserve literal origins and typed local precedence without acquiring
managed values. Every original-ID lifecycle child has a durable prospective
record; only its one-use known-return and process-group-absence proof settles that
record. Explicit recovery can contain an uncertain child but cannot clear its
uncertainty merely because containers are stopped.

Within one read-only dispatch proof, a private owner-issued context reuses its
entry resource observation while checking source, receipt, claims and lease
authority throughout. Routing, ingress and foreign-site observations remain
fresh. A complete resource/runtime observation brackets the proof, including
final volume and inventory rereads. The context is revoked before return and
cannot cross a lifecycle effect, publication or another observation phase.
This reduces nested inspection calls; it is not a measured runtime or CPU claim.
Before moving originals, publication uses its just-completed resource observation
as that proof's entry. Candidate admission and stopped-state reads do not extend
the observation into an effect: source, receipt, claims and stopped state are
rechecked inside the scoped context, and a complete fresh binding remains the
exit gate before originals can move. The same absolute publication deadline
applies; an already expired entry still refuses.

At the final active-receipt boundary, publication captures the actual final
resource read and reuses only its entry observation across the exclusive temporary
receipt write. The authoritative switching receipt is still unchanged. Scoped
source, manifest, claim, receipt, stopped-state and deadline checks continue;
the full closing resource binding and final receipt snapshot run before rename.
This reuse cannot cross an authoritative save, native installation, original
move, lifecycle effect or another callback. Clock controls cover this installed
native boundary separately from the earlier pre-move routing proof; they do not
identify a historical runtime refusal by themselves.

Publication refusals may include
`legacy_adoption_publication_refusal: {stage, reason}` in the JSON error detail.
The active-receipt save distinguishes its original context, receipt, staging,
routing, commit and readback boundaries. Its explicit deadline guard may issue
`proof-deadline`; other state refusals retain their owner code classification.
An inner issued diagnostic survives the outer publication catch. These fixed
labels contain no paths, identities, source text or error messages.
The owner records the fixed boundary that rejected and a closed error category;
it retains no source values, resource identities, compiler output or error text.
The public error code, guard order, deadlines and recovery requirements remain
unchanged. Copied details do not confer diagnostic or recovery authority. A
category describes that future invocation, not the cause of an older refusal
that did not record it.

Rollback requires restored source bytes, stopped original resources and absent
proxy dispatch before handing hostname claims back to the restored legacy source.
It retains a durable handoff state across interrupted claim removal. Builds, jobs,
readiness, source binds, files, branch overrides and custom bridges remain outside
this initial routing family. The owner and private-store model do not prove live
TLS, SQL fidelity, OAuth login or application acceptance. The maintained isolated
ingress lifecycle fixture remains required; global DNS and trust are unchanged.

The explicitly selected `native-compose-adoption-routing-worktrees` scenario
adds an HTTP service to the original two-worktree PostgreSQL fixture. It requires
both literal HTTPS origins and the existing OAuth alias to serve each checkout's
marker, with no published proxy ports. Saved `open` must select alpha's
checkout-local alias over the authored and primary-local dev preference. It also
checks original SQL/IDs/births, a known partial-stop journal and explicit
recovery, two retained up/down cycles, exact source rollback and claim handoff.
Unknown child or cleanup disposition retains the fixture. The temporary Caddy
owner is shared with `native-config-routing`; stopped user proxies and the
existing `hack-dev` network stay intact. This scenario does not perform OAuth
login, change host DNS/trust, or qualify combined unsupported families.

With current compiled artifacts, cached fixture images and an exclusively
coordinated Docker lane, select it using the same prerequisites above and
`--only=native-compose-adoption-routing-worktrees`. Its source and pure controls
are separate from a completed live TLS/SQL run.
## Retained directory binds

Version 12 is a distinct retained source-directory owner. It accepts literal
Expand Down
14 changes: 14 additions & 0 deletions src/commands/config-adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {
openLegacyComposeAdoptedGenerationStore,
} from "../lib/native-compose-adoption-generation.ts";
import { previewLegacyComposeAdoption } from "../lib/native-compose-adoption-preview.ts";
import { legacyComposePublicationRefusal } from "../lib/native-compose-adoption-publication-diagnostics.ts";
import { legacyComposeRetainedOrdered } from "../lib/native-compose-adoption-readiness.ts";
import { runLegacyComposeRetainedRoutingOperation } from "../lib/native-compose-adoption-routing-execution.ts";
import { requireNativeComposeBackend } from "../lib/native-compose-selection.ts";
import { run } from "../lib/shell.ts";

Expand Down Expand Up @@ -117,6 +119,14 @@ async function adoptPrepared(
if (opts.signal.aborted) {
throw new Error("Legacy adoption cancelled; values omitted.");
}
if (input.retainedRouting) {
return await runLegacyComposeRetainedRoutingOperation({
input,
operation: "stop",
deadline,
signal: opts.signal,
});
}
if (
legacyComposeRetainedOrdered(input.retainedPlan) ||
input.retainedBuild ||
Expand Down Expand Up @@ -242,12 +252,16 @@ export const configAdoptCommand = withHandler(spec, async ({ ctx, args }) => {
branch: args.options.branch,
});
} catch (error: unknown) {
const diagnostic = legacyComposePublicationRefusal(error);
throw new HackCliError({
code: "E_CONFIG_INVALID",
message:
error instanceof LegacyComposeAdoptedGenerationError
? error.message
: "Legacy adoption refused; original data and recovery evidence retained. Values omitted.",
...(diagnostic
? { detail: { legacy_adoption_publication_refusal: diagnostic } }
: {}),
});
} finally {
process.off("SIGINT", cancel);
Expand Down
118 changes: 106 additions & 12 deletions src/lib/native-compose-adoption-binding.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolve } from "node:path";
import { DEFAULT_INGRESS_NETWORK } from "../constants.ts";
import { isRecord } from "./guards.ts";
import {
acquireLegacyComposeBranch,
Expand All @@ -21,17 +22,26 @@ import {
type LegacyComposeStorageIntent,
planLegacyComposeAdoption,
planLegacyComposeRetainedBasicBuildAdoption,
planLegacyComposeRetainedRoutingAdoption,
planLegacyComposeSourceBindAdoption,
} from "./native-compose-adoption-plan.ts";
import {
hasLegacyComposeGeneratedSources,
LegacyComposeAdoptionProjection,
} from "./native-compose-adoption-projection.ts";
import { legacyComposeRetainedPlan } from "./native-compose-adoption-readiness.ts";
import {
inspectLegacyComposeRetainedRouting,
type LegacyComposeRetainedRoutingProof,
} from "./native-compose-adoption-routing.ts";
import {
acquireLegacyComposeSourceBind,
type LegacyComposeSourceBindProof,
} from "./native-compose-adoption-source-bind.ts";
import {
type NativeComposeIngressBinding,
observeNativeComposeIngress,
} from "./native-compose-ingress.ts";
import {
createNativeComposeProbe,
NativeComposeOwnershipError,
Expand All @@ -43,6 +53,7 @@ import {
import {
freezeImportValue,
mapLegacyNativeBranchStorageAdoption,
mapLegacyNativeRetainedRouting,
mapLegacyNativeStorageAdoption,
} from "./native-config-import-plan.ts";
import type { LegacyComposeSourceBindIntent } from "./native-config-import-storage.ts";
Expand Down Expand Up @@ -332,6 +343,12 @@ export type LegacyComposeVerifiedBinding = LegacyComposeVerifiedBindingBase &
readonly composeFiles: readonly string[];
readonly networks: readonly LegacyComposeVerifiedNetwork[];
}
| {
readonly binding_version: 14;
readonly composeFiles: readonly string[];
readonly network: LegacyComposeOriginalNetwork;
readonly routing: LegacyComposeRetainedRoutingProof;
}
| {
readonly binding_version: 13;
readonly composeFiles: readonly string[];
Expand Down Expand Up @@ -482,6 +499,7 @@ function containerRows(
readonly network?: { readonly name: string; readonly id: string };
readonly networks?: readonly LegacyComposeVerifiedNetwork[];
readonly composeFiles: readonly string[];
readonly ingress?: NativeComposeIngressBinding;
readonly sourceBinds?: LegacyComposeSourceBindIntent["sourceBinds"];
}
): LegacyComposeVerifiedContainer[] {
Expand Down Expand Up @@ -576,8 +594,36 @@ function containerRows(
}
return { id: row.id, name: row.name.slice(1), service: row.service };
}
requireValue(Array.isArray(row.networks) && row.networks.length === 1);
const network = row.networks[0];
requireValue(Array.isArray(row.networks));
const routed =
opts.intent.routing?.routes.some(
(route) => route.service === row.service
) === true;
if (opts.intent.routing) {
requireValue(
opts.ingress &&
!opts.intent.ownedNetwork &&
!opts.intent.ownedNetworks &&
row.networks.length === (routed ? 2 : 1)
);
const shared = row.networks.filter(
(item) => isRecord(item) && item.name === DEFAULT_INGRESS_NETWORK
);
requireValue(routed ? shared.length === 1 : shared.length === 0);
if (routed) {
const item = shared[0];
requireValue(isRecord(item));
keys(item, ["id", "name"]);
requireValue(item.id === opts.ingress.networkId);
}
} else {
requireValue(row.networks.length === 1);
}
const local = row.networks.filter(
(item) => !isRecord(item) || item.name !== DEFAULT_INGRESS_NETWORK
);
requireValue(local.length === 1);
const network = local[0];
requireValue(isRecord(network));
keys(
network,
Expand Down Expand Up @@ -863,6 +909,7 @@ async function inspectResources(
};
let { plural, single } = await readNetwork();
const networkIdentity = JSON.stringify(plural?.networks ?? single?.network);
let ingress: NativeComposeIngressBinding | undefined;
const readContainers = async () => {
const facts = await inspect({
kind: "container",
Expand All @@ -874,12 +921,19 @@ async function inspectResources(
opts.intent.ownedNetworks !== undefined,
sourceBinds: sourceBinds !== undefined,
});
// Retain the original routed observation after the first container read.
// The complete routing proof below rechecks this same ingress incarnation.
if (opts.intent.routing && ingress === undefined) {
ingress = await observeNativeComposeIngress({ signal: opts.signal });
requireValue(ingress.engineId === engineId);
}
const containers = containerRows(facts, {
...opts,
composeFiles,
volumes,
network: single?.network,
networks: plural?.networks,
ingress,
sourceBinds,
});
return { facts, containers };
Expand Down Expand Up @@ -989,6 +1043,31 @@ async function inspectResources(
};
}
requireValue(single);
if (opts.intent.routing) {
requireValue(
!(
plural ||
sourceBinds ||
opts.selectedBranch ||
opts.intent.ownedNetwork ||
opts.intent.ownedNetworks
) && ingress
);
const routing = await inspectLegacyComposeRetainedRouting({
binding: common,
routing: opts.intent.routing,
signal: opts.signal,
timeoutMs: opts.timeoutMs,
});
requireValue(JSON.stringify(routing.ingress) === JSON.stringify(ingress));
return {
...common,
binding_version: 14,
composeFiles: Object.freeze(composeFiles),
network: single.network,
routing,
};
}
if (sourceBinds) {
requireValue(
!(
Expand Down Expand Up @@ -1158,25 +1237,36 @@ async function acquireBinding(
composeText: source.composeText,
selectedComposeProject: branch?.proof.composeProject,
});
const routed =
purpose === "preparation" && !ordinary.intent && !branch
? planLegacyComposeRetainedRoutingAdoption(source)
: undefined;
const basicPlan =
purpose === "basic-build" ||
(purpose === "preparation" && !ordinary.intent)
(purpose === "preparation" && !ordinary.intent && !routed?.intent)
? planLegacyComposeRetainedBasicBuildAdoption(source)
: undefined;
const basic =
purpose === "basic-build" ||
(purpose === "preparation" &&
!ordinary.intent &&
!routed?.intent &&
!branch &&
Boolean(basicPlan?.intent));
const sources =
purpose === "source-bind" ||
(purpose === "preparation" && !ordinary.intent && !basic && !branch);
if (branch && (basic || sources || !ordinary.intent)) {
(purpose === "preparation" &&
!ordinary.intent &&
!routed?.intent &&
!basic &&
!branch);
if (branch && (basic || sources || routed?.intent || !ordinary.intent)) {
refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED");
}
let planned = ordinary;
if (sources) {
if (routed?.intent) {
planned = routed;
} else if (sources) {
planned = planLegacyComposeSourceBindAdoption(source);
} else if (basic && basicPlan) {
planned = basicPlan;
Expand All @@ -1191,11 +1281,13 @@ async function acquireBinding(
const sourceBind = sources
? await acquireLegacyComposeSourceBind({ source, signal })
: undefined;
const mapped = (
branch
? mapLegacyNativeBranchStorageAdoption
: mapLegacyNativeStorageAdoption
)({
let mapper = mapLegacyNativeStorageAdoption;
if (intent.routing) {
mapper = mapLegacyNativeRetainedRouting;
} else if (branch) {
mapper = mapLegacyNativeBranchStorageAdoption;
}
const mapped = mapper({
configText: source.configText,
composeText: source.composeText,
});
Expand Down Expand Up @@ -1227,7 +1319,8 @@ async function acquireBinding(
);
if (
candidate &&
(generatedPresent ||
(intent.routing !== undefined ||
generatedPresent ||
!(await legacyComposeAdoptionLayoutSupported({
projectRoot: root,
candidate,
Expand All @@ -1241,6 +1334,7 @@ async function acquireBinding(
source,
signal,
binary,
retainedRouting: intent.routing !== undefined,
});
const resolved = await projection.resolve({ signal });
projected = Object.freeze({
Expand Down
Loading
Loading