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
2 changes: 1 addition & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ This ledger holds **115** rows. The compiler's behaviours join it as they land.
| REQ-047 | A fragment's share of the images lock holds the aliases its project file names and no other, refusing one the lock it is handed does not hold, and an entry that records no version at its alias; composition unions every share into the one lock resolution reads, taking an alias locked to the same image as one entry and refusing one locked differently at the share of the fragment that changed, which isolates that Project | [test/application/images-lock-shares.test.ts](../test/application/images-lock-shares.test.ts) |
| REQ-048 | A Process that declares Kubernetes API access holds it only where the Platform document admits its Application: an admitted one renders a ClusterRole of its declared rules bound to its one ServiceAccount, a mounted token and egress to where the API answers, and the delivery machinery it is declared for renders, rolling and ungated, to its committed tree; a declaration nobody admitted is refused, and so is a holder no project file declares | [test/adapters/spelling.test.ts](../test/adapters/spelling.test.ts) |
| REQ-049 | The Vault policy job is rendered where the Platform document names it, a Secret Store answers and the render holds a document to write: one per Project that holds a document: a ServiceAccount, a ConfigMap of exactly that Project's documents and a Job, in that Project's own share and namespace, the ConfigMap and the Job named by the digest of those documents and what the pod runs with, with a policy of its own | [test/model/render.test.ts](../test/model/render.test.ts) |
| REQ-050 | Composition reads the participants list: a participant that published nothing, or nothing within its age, is held at the fragment it last composed at, a fragment the list does not name is left out, and each is recorded in the lock with its code; a Platform document that is missing, stale or unlisted, and a dormant entry past its review, stop the run | [test/application/compose.test.ts](../test/application/compose.test.ts) |
| REQ-050 | Composition reads the participants list: a participant that published nothing, or nothing within its age, or whose fragment was written at a model version the toolkit does not accept (another major, or a newer minor), is held at the fragment it last composed at, a fragment the list does not name is left out, and each is recorded in the lock with its code; a Platform document that is missing, stale, unlisted or written at such a version, a fragment a Project would be held or rolled back at that is written at such a version, and a dormant entry past its review, stop the run | [test/application/compose.test.ts](../test/application/compose.test.ts) |
| REQ-051 | Composition returns the pin source of every artifact whose pin moves, one it was handed no pin for among them: an `OCIRepository` naming the artifact by a digest that names none until it is published, verified keyless against the Platform document's signer matched whole, and one Kustomization per Reconcile Unit the artifact holds, each after the units it follows, and, on a Project's first delivery alone, a patch marking every Deployment for recreation, and, on every unit that carries a Rollout, patches labelling each pod template and Job it applies with the Rollout's version; an artifact whose pin stays has one written only where its committed source holds other objects than the one derived, however either is written, and then at the digest it is committed at, a paused Project's with its units as committed | [test/application/compose.test.ts](../test/application/compose.test.ts) |
| REQ-052 | An Application that moves its schema with a changelog renders its migration identity and two Jobs named by the revision's tag, suspended and created once: the up, and the down to the tag of the revision the release was proven against, none on a first release; the identity holds the owner credential under a policy and role of its own, and the datastore admits it. A count above one renders a disruption budget, and for a blue-green Process an autoscaler with equal bounds the Canary names; auth's share and its estate-scoped share equal their committed tree byte for byte | [test/model/render.test.ts](../test/model/render.test.ts) |
| REQ-053 | A file grant of one key renders as a projected Secret volume carrying that key as the file at `mountAt`, at `fileMode`, its directory mounted read-only into the Process and every sidecar and nothing of it in the environment; a file grant of other than one key, with no mode, or directly in `/` stops the render. The knowledge platform's share and its estate-scoped share equal their committed tree byte for byte | [test/model/render.test.ts](../test/model/render.test.ts) |
Expand Down
1 change: 1 addition & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
".": {
"release-type": "node",
"changelog-path": "CHANGELOG.md",
"extra-files": ["src/cli/main.ts"],
"include-component-in-tag": false,
"exclude-paths": ["emf"],
"bump-minor-pre-major": true,
Expand Down
1 change: 0 additions & 1 deletion scripts/lint-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ export const PENDING: readonly Pending[] = [
"E_RAW_SECRET",
"E_READER_NOT_DECLARED",
"E_ROLL_AFFECTS_OTHER_READERS",
"E_SCHEMA_VERSION_MISMATCH",
"E_UNDECLARED_SECRET_PATH",
],
},
Expand Down
11 changes: 8 additions & 3 deletions spec/v1/40-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,14 @@ composes as if nothing happened.
participant (`E_PARTICIPANT_MISSING`, `E_PARTICIPANT_STALE`), and one whose
`schemaVersion` the toolkit does not accept (`E_SCHEMA_VERSION_MISMATCH`), is
isolated at its last composed fragment even though its digest did not change:
the error is about that participant alone, so it is the one held back. A
toolkit upgrade that no participant's version accepts therefore holds every
Project still and fails nothing, until each republishes.
the error is about that participant alone, so it is the one held back. Where
the fragment it would be held at, or the one a Rollback names, is itself one
the toolkit does not accept, nothing is left to hold it at, and composing it
would drop what the toolkit cannot read: the run stops with
`E_SCHEMA_VERSION_MISMATCH`, naming the Project and that fragment, until the
fragment is republished or the toolkit reads it. A toolkit upgrade a major
ahead of the fragments therefore stops the run, which the
[version rollout](#version-rollout) avoids by republishing first.
- **A fragment nobody expected is left out.** A fragment whose project the
participants list does not name is `E_PARTICIPANT_UNLISTED`: it is isolated
and not composed at all, whether or not its Project composed before. Nobody
Expand Down
104 changes: 86 additions & 18 deletions src/application/compose-estate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,33 @@ const PROJECT_FILE = ".project.yml";
interface Isolation {
readonly refused: Fragment | undefined;
readonly codes: readonly string[];
/** Why its fragment was written at a version the toolkit refuses, where it was. */
readonly written?: string | undefined;
}

/** What holds a participant back before the union composes, and why. */
interface Standing {
readonly code: string;
readonly written?: string;
}

/**
* Whether the toolkit reads the model version a fragment was written at: a
* range, not equality (spec/v1/40-composition.md#versioning). Another major,
* either way, or a newer minor is refused, since an older toolkit would drop
* what it cannot read and exit zero; a patch never gates.
*/
function versionRefusal(written: string, toolkit: string): string | undefined {
const [major, minor] = written.split(".").map(Number) as [number, number];
const [ownMajor, ownMinor] = toolkit.split(".").map(Number) as [
number,
number,
];
if (major !== ownMajor)
return `written at ${written}, a different major than this toolkit's ${toolkit}`;
return minor > ownMinor
? `written at ${written}, a newer minor than this toolkit's ${toolkit}`
: undefined;
}

/** A composition whose Platform document is there to compose with. */
Expand Down Expand Up @@ -238,15 +265,21 @@ function declaredBy({ files }: Fragment): string[] {
*/
function standingOf(
input: ComposeInput,
generatedAt: string,
options: Pick<ComposeOptions, "generatedAt" | "schemaVersion">,
project: string,
fragment: Fragment | undefined,
): string | undefined {
): Standing | undefined {
const entry = own(input.participants.participants, project);
if (entry === undefined) return "E_PARTICIPANT_UNLISTED";
if (fragment === undefined) return "E_PARTICIPANT_MISSING";
if (entry === undefined) return { code: "E_PARTICIPANT_UNLISTED" };
if (fragment === undefined) return { code: "E_PARTICIPANT_MISSING" };
if (declaredBy(fragment).some((declared) => declared !== project))
return "E_PARTICIPANT_UNLISTED";
return { code: "E_PARTICIPANT_UNLISTED" };
const version = versionRefusal(
fragment.manifest.spec.schemaVersion,
options.schemaVersion,
);
if (version !== undefined)
return { code: "E_SCHEMA_VERSION_MISMATCH", written: version };
// Dormant exempts a participant from its age, and from nothing else.
if (entry.dormant === true) return undefined;
const at = own(input.published, project);
Expand All @@ -255,39 +288,71 @@ function standingOf(
`${project}: a fragment whose publish time the caller did not supply`,
);
const days = Number((entry.maxAge ?? DEFAULT_MAX_AGE).slice(0, -1));
return Date.parse(generatedAt) - Date.parse(at) > days * DAY_MS
? "E_PARTICIPANT_STALE"
return Date.parse(options.generatedAt) - Date.parse(at) > days * DAY_MS
? { code: "E_PARTICIPANT_STALE" }
: undefined;
}

const PARTICIPANTS = "participants.yml";

/** What stops the run before anything composes: the list's own entries, and the platform's standing. */
function listRefusals(input: ComposeInput, generatedAt: string): Diagnostic[] {
function listRefusals(
input: ComposeInput,
options: ComposeOptions,
): Diagnostic[] {
const platform = input.platform;
const project = platform?.manifest.spec.project;
const standing =
project === undefined
? "E_PARTICIPANT_MISSING"
: standingOf(input, generatedAt, project, platform);
? { code: "E_PARTICIPANT_MISSING" }
: standingOf(input, options, project, platform);
return [
...(standing === undefined
? []
: [
{
code: standing,
code: standing.code,
document: PARTICIPANTS,
path: "/participants",
message:
project === undefined
? "the Platform document published no fragment"
: `the Platform document's fragment, ${project}, cannot be composed with: ${standing}`,
: `the Platform document's fragment, ${project}, cannot be composed with: ${standing.code}${standing.written === undefined ? "" : `: ${(platform as Fragment).ref} was ${standing.written}`}`,
hint: "No render is composed without a current, listed Platform document: publish it, or correct its entry.",
} as Diagnostic,
]),
];
}

/**
* A fragment the union would compose that the toolkit cannot read: the one a
* Project is held at, or a Rollback's. Nothing is left to hold it at, and
* composing it would drop what the toolkit cannot read, so the run stops
* (spec/v1/40-composition.md#versioning).
*/
function unreadable(
candidates: ReadonlyMap<string, Fragment>,
schemaVersion: string,
): Diagnostic[] {
return [...candidates].flatMap(([project, fragment]) => {
const written = versionRefusal(
fragment.manifest.spec.schemaVersion,
schemaVersion,
);
return written === undefined
? []
: [
{
code: "E_SCHEMA_VERSION_MISMATCH",
document: PARTICIPANTS,
path: "/participants",
message: `${project} can be composed only at ${fragment.ref}, which was ${written}`,
hint: "Republish the fragment at a model version this toolkit reads, or compose with a toolkit that reads it.",
},
];
});
}

const placed = (directory: string, files: readonly AuthoredFile[]) =>
files.map(({ name, text }) => ({ name: `${directory}/${name}`, text }));

Expand Down Expand Up @@ -389,7 +454,7 @@ export function composeEstate(
): Result<Composition> {
// A Platform document that is missing, stale or unlisted stops the run:
// there is nothing to isolate.
const refusals = listRefusals(input, options.generatedAt);
const refusals = listRefusals(input, options);
return refusals.length > 0
? { ok: false, diagnostics: refusals }
: composed(input as Composing, options);
Expand Down Expand Up @@ -437,9 +502,10 @@ function composed(
);
for (const project of [...new Set([...expected, ...newest.keys()])]) {
const fragment = newest.get(project);
const code = standingOf(input, options.generatedAt, project, fragment);
if (code === undefined) continue;
isolated.set(project, { refused: fragment, codes: [code] });
const standing = standingOf(input, options, project, fragment);
if (standing === undefined) continue;
const { code, written } = standing;
isolated.set(project, { refused: fragment, codes: [code], written });
const earlier = previous[project]?.ref;
if (code === "E_PARTICIPANT_UNLISTED" || earlier === undefined)
candidates.delete(project);
Expand All @@ -452,6 +518,8 @@ function composed(
}

for (;;) {
const stops = unreadable(candidates, options.schemaVersion);
if (stops.length > 0) return { ok: false, diagnostics: stops };
const files = [
...placed(PLATFORM, input.platform.files),
{ name: `${PLATFORM}/${CLUSTER_STATE}`, text: input.clusterState.text },
Expand Down Expand Up @@ -820,7 +888,7 @@ function conditionsOf(
): Condition[] {
const conditions: Condition[] = [...isolated]
.sort(([a], [b]) => byText(a, b))
.map(([project, { refused, codes }]): Condition => {
.map(([project, { refused, codes, written }]): Condition => {
if (refused === undefined)
return {
project,
Expand All @@ -834,7 +902,7 @@ function conditionsOf(
project,
condition: "isolated",
title: `${project}: ${version} isolated`,
body: `The fragment ${refused.ref} was refused with ${named(codes)}. ${project} stays at the fragment it last composed at, and its pin does not move until a release composes.`,
body: `The fragment ${refused.ref} was refused with ${named(codes)}${written === undefined ? "" : `: it was ${written}`}. ${project} stays at the fragment it last composed at, and its pin does not move until a release composes.`,
discord: `${project} ${version} isolated: ${named(codes)}`,
};
});
Expand Down
4 changes: 2 additions & 2 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export interface World {
*/
export const EXIT = { accepted: 0, refused: 1, usage: 2 } as const;

/** The model version this toolkit writes. */
const SCHEMA_VERSION = "1.0.0";
/** The model version this toolkit writes and admits up to: the package's own, set by release-please. */
export const SCHEMA_VERSION = "1.0.0"; // x-release-please-version
const MANIFEST = "fragment.yml";
const PLATFORM_DOCUMENT = "platform.intent.yml";
const PROOF = "migration-proof.yml";
Expand Down
Loading
Loading