@caelo-cms/provisioning 0.10.64 → 0.10.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-db.js +103 -56
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +1 -1
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/install-state.d.ts +17 -2
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +11 -3
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +4 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +12 -2
- package/dist/lifecycle.js.map +1 -1
- package/dist/release-manifest.d.ts +10 -4
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +17 -8
- package/dist/release-manifest.js.map +1 -1
- package/dist/upgrade/__tests__/live-indexes.d.ts +23 -0
- package/dist/upgrade/__tests__/live-indexes.d.ts.map +1 -0
- package/dist/upgrade/__tests__/live-indexes.js +41 -0
- package/dist/upgrade/__tests__/live-indexes.js.map +1 -0
- package/dist/upgrade/gcp-lookup.d.ts +22 -13
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +42 -3
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/image-forms.d.ts +55 -0
- package/dist/upgrade/image-forms.d.ts.map +1 -0
- package/dist/upgrade/image-forms.js +164 -0
- package/dist/upgrade/image-forms.js.map +1 -0
- package/dist/upgrade/release-images.d.ts +24 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +30 -0
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +3 -0
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-job.d.ts +8 -0
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +51 -10
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +27 -2
- package/dist/upgrader-records.d.ts.map +1 -1
- package/dist/upgrader-records.js +77 -19
- package/dist/upgrader-records.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +8 -1
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/converge-job-gcp.ts +1 -0
- package/src/install-state.test.ts +32 -0
- package/src/install-state.ts +21 -2
- package/src/lifecycle.ts +19 -2
- package/src/release-manifest.test.ts +27 -2
- package/src/release-manifest.ts +20 -7
- package/src/upgrade/__tests__/fixtures/admin-0.10.64-index.json +28 -0
- package/src/upgrade/__tests__/fixtures/gateway-0.10.64-index.json +28 -0
- package/src/upgrade/__tests__/live-indexes.ts +61 -0
- package/src/upgrade/gcp-lookup.test.ts +53 -9
- package/src/upgrade/gcp-lookup.ts +60 -15
- package/src/upgrade/image-forms.test.ts +192 -0
- package/src/upgrade/image-forms.ts +226 -0
- package/src/upgrade/release-images.ts +49 -0
- package/src/upgrader-gcp.ts +3 -0
- package/src/upgrader-job.test.ts +153 -0
- package/src/upgrader-job.ts +73 -12
- package/src/upgrader-records.test.ts +202 -0
- package/src/upgrader-records.ts +115 -19
- package/src/wizards/gcp.ts +17 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1025: every digest a release image is known by, read from the signed
|
|
5
|
+
* manifest or from the content-checked index in the fixed mirror. Run
|
|
6
|
+
* against the v0.10.64 indexes exactly as the mirror serves them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
ADMIN_AMD64,
|
|
12
|
+
ADMIN_ATTESTATION,
|
|
13
|
+
ADMIN_INDEX,
|
|
14
|
+
fakeRegistryFetch,
|
|
15
|
+
GATEWAY_AMD64,
|
|
16
|
+
GATEWAY_INDEX,
|
|
17
|
+
LIVE_INDEXES,
|
|
18
|
+
} from "./__tests__/live-indexes.js";
|
|
19
|
+
import { createReleaseImageForms, formsFromIndexJson, platformDigestsOf } from "./image-forms.js";
|
|
20
|
+
|
|
21
|
+
const D = (c: string) => `sha256:${c.repeat(64)}`;
|
|
22
|
+
const MIRROR = "https://europe-west1-docker.pkg.dev/v2/caelo-website/caelo-cms-images";
|
|
23
|
+
|
|
24
|
+
describe("the fixtures", () => {
|
|
25
|
+
it("are the bytes of the indexes they are named after", async () => {
|
|
26
|
+
for (const [digest, bytes] of Object.entries(LIVE_INDEXES)) {
|
|
27
|
+
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
28
|
+
expect(`sha256:${Buffer.from(hash).toString("hex")}`).toBe(digest);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("createReleaseImageForms().ofIndex", () => {
|
|
34
|
+
it("regression #1025: the v0.10.64 admin index lists its linux/amd64 manifest, not the attestation", async () => {
|
|
35
|
+
const calls: string[] = [];
|
|
36
|
+
const forms = createReleaseImageForms(fakeRegistryFetch(calls));
|
|
37
|
+
const r = await forms.ofIndex("admin", ADMIN_INDEX);
|
|
38
|
+
expect(r).toEqual({ ok: true, digests: [ADMIN_INDEX, ADMIN_AMD64], amd64: ADMIN_AMD64 });
|
|
39
|
+
expect(r.ok && r.digests.includes(ADMIN_ATTESTATION)).toBe(false);
|
|
40
|
+
// Fetched by digest from the fixed mirror.
|
|
41
|
+
expect(calls).toEqual([`${MIRROR}/admin/manifests/${ADMIN_INDEX}`]);
|
|
42
|
+
expect(await forms.ofIndex("gateway", GATEWAY_INDEX)).toEqual({
|
|
43
|
+
ok: true,
|
|
44
|
+
digests: [GATEWAY_INDEX, GATEWAY_AMD64],
|
|
45
|
+
amd64: GATEWAY_AMD64,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reads each index once per instance", async () => {
|
|
50
|
+
const calls: string[] = [];
|
|
51
|
+
const forms = createReleaseImageForms(fakeRegistryFetch(calls));
|
|
52
|
+
await forms.ofIndex("admin", ADMIN_INDEX);
|
|
53
|
+
await forms.ofIndex("admin", ADMIN_INDEX);
|
|
54
|
+
expect(calls).toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("refuses bytes that do not hash to the digest asked for (a tampering registry)", async () => {
|
|
58
|
+
const forged = new TextEncoder().encode(
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
mediaType: "application/vnd.oci.image.index.v1+json",
|
|
61
|
+
manifests: [{ digest: D("6"), platform: { os: "linux", architecture: "amd64" } }],
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const forms = createReleaseImageForms(fakeRegistryFetch([], { tampered: forged }));
|
|
65
|
+
const r = await forms.ofIndex("admin", ADMIN_INDEX);
|
|
66
|
+
expect(r.ok).toBe(false);
|
|
67
|
+
expect(r.ok ? null : r.transient).toBe(false);
|
|
68
|
+
expect(r.ok ? "" : r.message).toContain("does not match its digest");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("an outage is transient, a missing index is not", async () => {
|
|
72
|
+
for (const [opts, transient] of [
|
|
73
|
+
[{ status: 503 }, true],
|
|
74
|
+
[{ status: 429 }, true],
|
|
75
|
+
[{ unreachable: true }, true],
|
|
76
|
+
[{ status: 404 }, false],
|
|
77
|
+
] as const) {
|
|
78
|
+
const r = await createReleaseImageForms(fakeRegistryFetch([], opts)).ofIndex(
|
|
79
|
+
"admin",
|
|
80
|
+
ADMIN_INDEX,
|
|
81
|
+
);
|
|
82
|
+
expect(r.ok ? null : r.transient).toBe(transient);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("follows the registry's anonymous Bearer challenge", async () => {
|
|
87
|
+
const calls: string[] = [];
|
|
88
|
+
const inner = fakeRegistryFetch(calls);
|
|
89
|
+
const challenged = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
90
|
+
const url = String(input);
|
|
91
|
+
if (url.startsWith("https://token.example/")) {
|
|
92
|
+
return Response.json({ token: "anon" });
|
|
93
|
+
}
|
|
94
|
+
const auth = new Headers(init?.headers).get("authorization");
|
|
95
|
+
if (auth !== "Bearer anon") {
|
|
96
|
+
return new Response("", {
|
|
97
|
+
status: 401,
|
|
98
|
+
headers: { "www-authenticate": 'Bearer realm="https://token.example/t",service="x"' },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return inner(input, init);
|
|
102
|
+
}) as typeof fetch;
|
|
103
|
+
const r = await createReleaseImageForms(challenged).ofIndex("admin", ADMIN_INDEX);
|
|
104
|
+
expect(r.ok && r.amd64).toBe(ADMIN_AMD64);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("createReleaseImageForms().of", () => {
|
|
109
|
+
it("takes the signed platform digests without a registry round trip", async () => {
|
|
110
|
+
const calls: string[] = [];
|
|
111
|
+
const forms = createReleaseImageForms(fakeRegistryFetch(calls));
|
|
112
|
+
const m = {
|
|
113
|
+
version: "0.10.66",
|
|
114
|
+
digests: { admin: D("a"), gateway: D("b"), ops: D("c") },
|
|
115
|
+
platformDigests: { admin: D("1"), gateway: D("2"), ops: D("3") },
|
|
116
|
+
};
|
|
117
|
+
expect(await forms.of(m, "gateway")).toEqual({
|
|
118
|
+
ok: true,
|
|
119
|
+
digests: [D("b"), D("2")],
|
|
120
|
+
amd64: D("2"),
|
|
121
|
+
});
|
|
122
|
+
expect(calls).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reads the index for a release signed before the field", async () => {
|
|
126
|
+
const forms = createReleaseImageForms(fakeRegistryFetch([]));
|
|
127
|
+
const m = {
|
|
128
|
+
version: "0.10.64",
|
|
129
|
+
digests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX, ops: D("c") },
|
|
130
|
+
};
|
|
131
|
+
expect(await forms.of(m, "admin")).toMatchObject({ ok: true, amd64: ADMIN_AMD64 });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("formsFromIndexJson", () => {
|
|
136
|
+
it("a plain image manifest is its own platform form", () => {
|
|
137
|
+
const plain = { mediaType: "application/vnd.oci.image.manifest.v1+json", config: {} };
|
|
138
|
+
expect(formsFromIndexJson(D("7"), plain)).toEqual({
|
|
139
|
+
ok: true,
|
|
140
|
+
digests: [D("7")],
|
|
141
|
+
amd64: D("7"),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("an index without a linux/amd64 manifest has no amd64 form", () => {
|
|
146
|
+
const arm = {
|
|
147
|
+
mediaType: "application/vnd.oci.image.index.v1+json",
|
|
148
|
+
manifests: [{ digest: D("8"), platform: { os: "linux", architecture: "arm64" } }],
|
|
149
|
+
};
|
|
150
|
+
expect(formsFromIndexJson(D("7"), arm)).toEqual({
|
|
151
|
+
ok: true,
|
|
152
|
+
digests: [D("7"), D("8")],
|
|
153
|
+
amd64: null,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("an unreadable shape is a non-transient error", () => {
|
|
158
|
+
const r = formsFromIndexJson(D("7"), { manifests: [{ digest: "nope" }] });
|
|
159
|
+
expect(r.ok ? null : r.transient).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("platformDigestsOf", () => {
|
|
164
|
+
it("reads the linux/amd64 form of both indexes", async () => {
|
|
165
|
+
const forms = createReleaseImageForms(fakeRegistryFetch([]));
|
|
166
|
+
expect(await platformDigestsOf({ admin: ADMIN_INDEX, gateway: GATEWAY_INDEX }, forms)).toEqual({
|
|
167
|
+
ok: true,
|
|
168
|
+
platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 },
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("prefers the manifest's signed forms when it signs these very indexes", async () => {
|
|
173
|
+
const calls: string[] = [];
|
|
174
|
+
const forms = createReleaseImageForms(fakeRegistryFetch(calls));
|
|
175
|
+
const m = {
|
|
176
|
+
version: "0.10.66",
|
|
177
|
+
digests: { admin: D("a"), gateway: D("b"), ops: D("c") },
|
|
178
|
+
platformDigests: { admin: D("1"), gateway: D("2"), ops: D("3") },
|
|
179
|
+
};
|
|
180
|
+
expect(await platformDigestsOf({ admin: D("a"), gateway: D("b") }, forms, m)).toEqual({
|
|
181
|
+
ok: true,
|
|
182
|
+
platformDigests: { admin: D("1"), gateway: D("2") },
|
|
183
|
+
});
|
|
184
|
+
expect(calls).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("says why when an index cannot be read", async () => {
|
|
188
|
+
const forms = createReleaseImageForms(fakeRegistryFetch([], { status: 503 }));
|
|
189
|
+
const r = await platformDigestsOf({ admin: ADMIN_INDEX, gateway: GATEWAY_INDEX }, forms);
|
|
190
|
+
expect(r.ok ? "" : r.message).toContain("HTTP 503");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every digest a release image is known by (#1025).
|
|
5
|
+
*
|
|
6
|
+
* Caelo's release images are OCI image *indexes*: buildx adds a
|
|
7
|
+
* provenance/SBOM attestation manifest even to a single-platform build. The
|
|
8
|
+
* signed release manifest, install.json and every deploy pin the index
|
|
9
|
+
* digest, but Cloud Run resolves the index to its linux/amd64 *platform
|
|
10
|
+
* manifest* and records that digest — in the revision's `status.imageDigest`
|
|
11
|
+
* AND in its `spec.containers[0].image` (verified on live installs,
|
|
12
|
+
* 2026-10-23: a revision deployed as `admin@sha256:bcda…` (the v0.10.64
|
|
13
|
+
* index) reads back as `admin@sha256:2e75…`, its linux/amd64 manifest). So
|
|
14
|
+
* "does release X serve?" must compare the serving digest with every form of
|
|
15
|
+
* X's images, not just the index.
|
|
16
|
+
*
|
|
17
|
+
* Where the forms come from, in order of trust:
|
|
18
|
+
* 1. the release manifest's signed `platformDigests` (releases cut after
|
|
19
|
+
* #1025) — no registry round trip;
|
|
20
|
+
* 2. otherwise the index itself, fetched BY DIGEST from the fixed public
|
|
21
|
+
* mirror and checked against that digest before it is read. Content
|
|
22
|
+
* addressing makes it as unforgeable as the signature that named the
|
|
23
|
+
* index: a registry or proxy that altered it fails the check.
|
|
24
|
+
*
|
|
25
|
+
* Only platform manifests count, never the attestation manifests listed next
|
|
26
|
+
* to them (Cloud Run cannot run those). Lookups are cached per instance, so
|
|
27
|
+
* one upgrader run fetches each index at most once.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import type { ImageDigests } from "../install-state.js";
|
|
32
|
+
import type { ReleaseManifest } from "../release-manifest.js";
|
|
33
|
+
import type { RollSlug } from "./app-roll.js";
|
|
34
|
+
import { fetchRegistryManifest, IMAGE_DIGEST, RELEASE_REGISTRY } from "./release-images.js";
|
|
35
|
+
|
|
36
|
+
/** The digests one release image is known by. */
|
|
37
|
+
export type ImageFormsResult =
|
|
38
|
+
| {
|
|
39
|
+
readonly ok: true;
|
|
40
|
+
/** The index digest first, then each platform manifest it lists. */
|
|
41
|
+
readonly digests: readonly string[];
|
|
42
|
+
/**
|
|
43
|
+
* The linux/amd64 form Cloud Run runs: the signed platform digest, the
|
|
44
|
+
* index's linux/amd64 entry, or the digest itself when it names a plain
|
|
45
|
+
* image manifest. Null when the index lists no linux/amd64 manifest.
|
|
46
|
+
*/
|
|
47
|
+
readonly amd64: string | null;
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
readonly ok: false;
|
|
51
|
+
/** A retry may cure it (registry unreachable, 5xx, 429). */
|
|
52
|
+
readonly transient: boolean;
|
|
53
|
+
readonly message: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Finds every form of a release image; see the file comment. */
|
|
57
|
+
export interface ReleaseImageForms {
|
|
58
|
+
/** The forms of release `m`'s image `slug` (signed platform digests win). */
|
|
59
|
+
of(
|
|
60
|
+
m: Pick<ReleaseManifest, "version" | "digests" | "platformDigests">,
|
|
61
|
+
slug: RollSlug,
|
|
62
|
+
): Promise<ImageFormsResult>;
|
|
63
|
+
/** The forms of the index `indexDigest` of image `slug`, read from the mirror. */
|
|
64
|
+
ofIndex(slug: RollSlug, indexDigest: string): Promise<ImageFormsResult>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const INDEX_MEDIA_TYPES = new Set([
|
|
68
|
+
"application/vnd.oci.image.index.v1+json",
|
|
69
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const digest = z.string().regex(IMAGE_DIGEST);
|
|
73
|
+
|
|
74
|
+
/** The fields of an image index (or plain image manifest) the lookup reads. */
|
|
75
|
+
const ManifestJsonSchema = z.object({
|
|
76
|
+
mediaType: z.string().optional(),
|
|
77
|
+
manifests: z
|
|
78
|
+
.array(
|
|
79
|
+
z.object({
|
|
80
|
+
digest,
|
|
81
|
+
platform: z.object({ os: z.string(), architecture: z.string() }).optional(),
|
|
82
|
+
annotations: z.record(z.string(), z.string()).optional(),
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
.optional(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
async function sha256Digest(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
|
|
89
|
+
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
90
|
+
return `sha256:${[...hash].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read the forms out of index bytes already checked against `indexDigest`.
|
|
95
|
+
* Exported for tests; production code goes through {@link createReleaseImageForms}.
|
|
96
|
+
*/
|
|
97
|
+
export function formsFromIndexJson(indexDigest: string, json: unknown): ImageFormsResult {
|
|
98
|
+
const parsed = ManifestJsonSchema.safeParse(json);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
transient: false,
|
|
103
|
+
message: `${indexDigest} is not an image index or manifest: ${parsed.error.message}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const { mediaType, manifests } = parsed.data;
|
|
107
|
+
const isIndex = mediaType ? INDEX_MEDIA_TYPES.has(mediaType) : manifests !== undefined;
|
|
108
|
+
// A plain image manifest is its own platform form.
|
|
109
|
+
if (!isIndex) return { ok: true, digests: [indexDigest], amd64: indexDigest };
|
|
110
|
+
const platforms = (manifests ?? []).filter(
|
|
111
|
+
(m) =>
|
|
112
|
+
m.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest" &&
|
|
113
|
+
m.platform !== undefined &&
|
|
114
|
+
m.platform.os !== "unknown",
|
|
115
|
+
);
|
|
116
|
+
const amd64 = platforms.filter(
|
|
117
|
+
(m) => m.platform?.os === "linux" && m.platform.architecture === "amd64",
|
|
118
|
+
);
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
digests: [indexDigest, ...platforms.map((m) => m.digest).filter((d) => d !== indexDigest)],
|
|
122
|
+
amd64: amd64.length === 1 ? (amd64[0]?.digest ?? null) : null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The lookup against the fixed public mirror ({@link RELEASE_REGISTRY}),
|
|
128
|
+
* anonymous, cached per instance.
|
|
129
|
+
*
|
|
130
|
+
* @param fetchImpl - injectable for tests; the real one by default
|
|
131
|
+
*/
|
|
132
|
+
export function createReleaseImageForms(fetchImpl: typeof fetch = fetch): ReleaseImageForms {
|
|
133
|
+
const cache = new Map<string, Promise<ImageFormsResult>>();
|
|
134
|
+
const { region, project, repo } = RELEASE_REGISTRY;
|
|
135
|
+
|
|
136
|
+
async function lookup(slug: RollSlug, indexDigest: string): Promise<ImageFormsResult> {
|
|
137
|
+
const where = `the ${slug} image index ${indexDigest} in the release mirror`;
|
|
138
|
+
const fetched = await fetchRegistryManifest(
|
|
139
|
+
`${region}-docker.pkg.dev`,
|
|
140
|
+
`${project}/${repo}/${slug}`,
|
|
141
|
+
indexDigest,
|
|
142
|
+
fetchImpl,
|
|
143
|
+
);
|
|
144
|
+
if (!fetched.ok) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
transient: fetched.transient,
|
|
148
|
+
message: `${where} could not be read (${fetched.reason})`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const actual = await sha256Digest(fetched.bytes);
|
|
152
|
+
if (actual !== indexDigest) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
transient: false,
|
|
156
|
+
message: `${where} does not match its digest (${actual} served): the registry or a proxy altered it`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
let json: unknown;
|
|
160
|
+
try {
|
|
161
|
+
json = JSON.parse(new TextDecoder().decode(fetched.bytes));
|
|
162
|
+
} catch {
|
|
163
|
+
return { ok: false, transient: false, message: `${where} is not JSON` };
|
|
164
|
+
}
|
|
165
|
+
return formsFromIndexJson(indexDigest, json);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ofIndex = (slug: RollSlug, indexDigest: string): Promise<ImageFormsResult> => {
|
|
169
|
+
const key = `${slug}@${indexDigest}`;
|
|
170
|
+
let pending = cache.get(key);
|
|
171
|
+
if (!pending) {
|
|
172
|
+
pending = lookup(slug, indexDigest);
|
|
173
|
+
cache.set(key, pending);
|
|
174
|
+
}
|
|
175
|
+
return pending;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
ofIndex,
|
|
180
|
+
of: async (m, slug) => {
|
|
181
|
+
const signed = m.platformDigests?.[slug];
|
|
182
|
+
if (signed) {
|
|
183
|
+
const index = m.digests[slug];
|
|
184
|
+
return { ok: true, digests: [index, ...(signed === index ? [] : [signed])], amd64: signed };
|
|
185
|
+
}
|
|
186
|
+
return ofIndex(slug, m.digests[slug]);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The linux/amd64 form of each image in `digests`, to record next to them
|
|
193
|
+
* in install.json (#1025). Taken from `manifest`'s signed platform digests
|
|
194
|
+
* when it signs these very indexes, else from the content-checked index.
|
|
195
|
+
*
|
|
196
|
+
* @returns the forms, or why they are not known (the caller records the
|
|
197
|
+
* index digests without them; the upgrader then reads the index itself)
|
|
198
|
+
*/
|
|
199
|
+
export async function platformDigestsOf(
|
|
200
|
+
digests: ImageDigests,
|
|
201
|
+
forms: ReleaseImageForms,
|
|
202
|
+
manifest?: Pick<ReleaseManifest, "version" | "digests" | "platformDigests"> | null,
|
|
203
|
+
): Promise<
|
|
204
|
+
| { readonly ok: true; readonly platformDigests: ImageDigests }
|
|
205
|
+
| { readonly ok: false; readonly message: string }
|
|
206
|
+
> {
|
|
207
|
+
const out: Partial<Record<RollSlug, string>> = {};
|
|
208
|
+
for (const slug of ["admin", "gateway"] as const) {
|
|
209
|
+
const found =
|
|
210
|
+
manifest && manifest.digests[slug] === digests[slug]
|
|
211
|
+
? await forms.of(manifest, slug)
|
|
212
|
+
: await forms.ofIndex(slug, digests[slug]);
|
|
213
|
+
if (!found.ok) return { ok: false, message: found.message };
|
|
214
|
+
if (!found.amd64) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
message: `the ${slug} image index ${digests[slug]} lists no single linux/amd64 manifest`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
out[slug] = found.amd64;
|
|
221
|
+
}
|
|
222
|
+
const { admin, gateway } = out;
|
|
223
|
+
return admin && gateway
|
|
224
|
+
? { ok: true, platformDigests: { admin, gateway } }
|
|
225
|
+
: { ok: false, message: "the platform digests could not be read" };
|
|
226
|
+
}
|
|
@@ -167,6 +167,55 @@ export async function resolveRegistryDigest(
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/** What {@link fetchRegistryManifest} read. */
|
|
171
|
+
export type RegistryManifestResult =
|
|
172
|
+
| { readonly ok: true; readonly bytes: Uint8Array<ArrayBuffer> }
|
|
173
|
+
| {
|
|
174
|
+
readonly ok: false;
|
|
175
|
+
/**
|
|
176
|
+
* True when a retry may cure it: the registry could not be reached,
|
|
177
|
+
* timed out, rate-limited (429/408), failed (5xx) or would not hand
|
|
178
|
+
* out a pull token. False when it answered that the manifest does not
|
|
179
|
+
* exist (404 and other 4xx).
|
|
180
|
+
*/
|
|
181
|
+
readonly transient: boolean;
|
|
182
|
+
readonly reason: string;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* GET the manifest (or index) `<registryHost>/<repository>@<reference>`
|
|
187
|
+
* anonymously, through the same Bearer-challenge handling as
|
|
188
|
+
* {@link resolveRegistryDigest}. Returns the raw bytes: a caller that asked
|
|
189
|
+
* by digest checks them against it (content addressing is the trust here).
|
|
190
|
+
*
|
|
191
|
+
* @example fetchRegistryManifest("europe-west1-docker.pkg.dev", "caelo-website/caelo-cms-images/admin", "sha256:…")
|
|
192
|
+
*/
|
|
193
|
+
export async function fetchRegistryManifest(
|
|
194
|
+
registryHost: string,
|
|
195
|
+
repository: string,
|
|
196
|
+
reference: string,
|
|
197
|
+
fetchImpl: typeof fetch = fetch,
|
|
198
|
+
): Promise<RegistryManifestResult> {
|
|
199
|
+
const url = `https://${registryHost}/v2/${repository}/manifests/${reference}`;
|
|
200
|
+
try {
|
|
201
|
+
let res = await fetchImpl(url, { headers: { Accept: MANIFEST_ACCEPT } });
|
|
202
|
+
if (res.status === 401) {
|
|
203
|
+
const token = await anonymousPullToken(res.headers.get("www-authenticate"), fetchImpl);
|
|
204
|
+
if (!token.ok) return { ok: false, transient: true, reason: token.reason };
|
|
205
|
+
res = await fetchImpl(url, {
|
|
206
|
+
headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token.token}` },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
const transient = res.status >= 500 || res.status === 429 || res.status === 408;
|
|
211
|
+
return { ok: false, transient, reason: `${url}: HTTP ${res.status} ${res.statusText}` };
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, bytes: new Uint8Array(await res.arrayBuffer()) };
|
|
214
|
+
} catch (e) {
|
|
215
|
+
return { ok: false, transient: true, reason: e instanceof Error ? e.message : String(e) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
170
219
|
/** Follow a registry's Bearer challenge to an anonymous pull token. */
|
|
171
220
|
async function anonymousPullToken(
|
|
172
221
|
challenge: string | null,
|
package/src/upgrader-gcp.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { acquireBucketStateLock, GcsStateStore, realSyncGcloud } from "./state-s
|
|
|
28
28
|
import type { AppRollPlan } from "./upgrade/app-roll.js";
|
|
29
29
|
import { findServiceUrl } from "./upgrade/gcp-lookup.js";
|
|
30
30
|
import { createIapHealthProbe } from "./upgrade/health.js";
|
|
31
|
+
import { createReleaseImageForms } from "./upgrade/image-forms.js";
|
|
31
32
|
import { type IamTester, restIamTester } from "./upgrader-iam-preflight.js";
|
|
32
33
|
import type { UpdateQueue, UpgraderJobDeps } from "./upgrader-job.js";
|
|
33
34
|
import type { JobLog } from "./upgrader-reporter.js";
|
|
@@ -134,6 +135,8 @@ export function gcpUpgraderDeps(
|
|
|
134
135
|
iam: gcloudIamTester(run, http),
|
|
135
136
|
// The default deps: the fixed registry and signer, never overridden.
|
|
136
137
|
fetchManifest: (version) => fetchVerifiedReleaseManifest(version),
|
|
138
|
+
// #1025 — the fixed public mirror, read by digest and content-checked.
|
|
139
|
+
imageForms: createReleaseImageForms(),
|
|
137
140
|
// #815 — the lock names this execution, so a later run can break it
|
|
138
141
|
// once Cloud Run says this one has ended (killed at its task timeout,
|
|
139
142
|
// OOM). Outside Cloud Run there is no execution to vouch for it.
|
package/src/upgrader-job.test.ts
CHANGED
|
@@ -15,7 +15,19 @@ import type { GcloudRunner } from "./gcloud-retry.js";
|
|
|
15
15
|
import type { InstallMetadata } from "./install-state.js";
|
|
16
16
|
import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
|
|
17
17
|
import { StateLockHeldError } from "./state-store.js";
|
|
18
|
+
import {
|
|
19
|
+
ADMIN_AMD64,
|
|
20
|
+
ADMIN_INDEX,
|
|
21
|
+
fakeRegistryFetch,
|
|
22
|
+
GATEWAY_AMD64,
|
|
23
|
+
GATEWAY_INDEX,
|
|
24
|
+
} from "./upgrade/__tests__/live-indexes.js";
|
|
18
25
|
import type { HealthOutcome, HealthProbe } from "./upgrade/health.js";
|
|
26
|
+
import {
|
|
27
|
+
createReleaseImageForms,
|
|
28
|
+
type ImageFormsResult,
|
|
29
|
+
type ReleaseImageForms,
|
|
30
|
+
} from "./upgrade/image-forms.js";
|
|
19
31
|
import { type IamTester, resourceName } from "./upgrader-iam-preflight.js";
|
|
20
32
|
import {
|
|
21
33
|
type ClaimedUpdate,
|
|
@@ -212,6 +224,11 @@ function setup(
|
|
|
212
224
|
lockDenied?: boolean;
|
|
213
225
|
/** The lock broke a dead run's lock on the way, with this message. */
|
|
214
226
|
brokeStaleLock?: string;
|
|
227
|
+
/**
|
|
228
|
+
* The release mirror (#1025): by default index `D(c)` lists platform
|
|
229
|
+
* manifest `P(c)`; `down` makes every index read fail as an outage.
|
|
230
|
+
*/
|
|
231
|
+
imageForms?: ReleaseImageForms | "down";
|
|
215
232
|
/** IAM's answer: permissions withheld per resource name. */
|
|
216
233
|
iamWithheld?: Record<string, readonly string[]>;
|
|
217
234
|
/** A resource IAM cannot be asked about. */
|
|
@@ -246,6 +263,10 @@ function setup(
|
|
|
246
263
|
}
|
|
247
264
|
return opts.manifest ?? { ok: true, manifest: MANIFEST };
|
|
248
265
|
},
|
|
266
|
+
imageForms:
|
|
267
|
+
typeof opts.imageForms === "object"
|
|
268
|
+
? opts.imageForms
|
|
269
|
+
: fakeImageForms(order, opts.imageForms === "down"),
|
|
249
270
|
lockState: (onBreak) => {
|
|
250
271
|
order.push("lock");
|
|
251
272
|
if (opts.locked) throw new StateLockHeldError("held by caelo installer");
|
|
@@ -271,6 +292,28 @@ function setup(
|
|
|
271
292
|
return { deps, order, q, written };
|
|
272
293
|
}
|
|
273
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The release mirror's indexes as {@link ReleaseImageForms}: index `D(c)`
|
|
297
|
+
* lists platform manifest `P(c)` (#1025). Signed platform digests win, as
|
|
298
|
+
* in createReleaseImageForms.
|
|
299
|
+
*/
|
|
300
|
+
function fakeImageForms(order: string[], down: boolean): ReleaseImageForms {
|
|
301
|
+
const ofIndex = async (slug: string, index: string): Promise<ImageFormsResult> => {
|
|
302
|
+
order.push(`index ${slug}@${index}`);
|
|
303
|
+
if (down) return { ok: false, transient: true, message: "the mirror answered HTTP 503" };
|
|
304
|
+
const platform = P(index.slice(-1));
|
|
305
|
+
return { ok: true, digests: [index, platform], amd64: platform };
|
|
306
|
+
};
|
|
307
|
+
return {
|
|
308
|
+
ofIndex,
|
|
309
|
+
of: async (m, slug) => {
|
|
310
|
+
const signed = m.platformDigests?.[slug];
|
|
311
|
+
if (signed) return { ok: true, digests: [m.digests[slug], signed], amd64: signed };
|
|
312
|
+
return ofIndex(slug, m.digests[slug]);
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
274
317
|
/** IAM granting everything asked except `withheld` per resource; `error` names a resource it cannot be asked about. */
|
|
275
318
|
function fakeIam(
|
|
276
319
|
order: string[],
|
|
@@ -765,6 +808,116 @@ describe("runUpgraderJob", () => {
|
|
|
765
808
|
expect(order).not.toContain("manifest 0.11.0");
|
|
766
809
|
});
|
|
767
810
|
|
|
811
|
+
describe("regression #1025: Cloud Run's revision spec names the platform manifest", () => {
|
|
812
|
+
/** Serving revisions whose spec AND status name only the platform digests (the live shape). */
|
|
813
|
+
const servingPlatform = (admin: string, gateway: string): [RegExp, Answer] => [
|
|
814
|
+
/^run revisions describe (\S+)/,
|
|
815
|
+
(l) => {
|
|
816
|
+
const d = l.includes("-admin-") ? admin : gateway;
|
|
817
|
+
return ok(revisionJson(d, d));
|
|
818
|
+
},
|
|
819
|
+
];
|
|
820
|
+
|
|
821
|
+
it("the live v0.10.64 shapes: identified by the signed indexes, records both forms, installs", async () => {
|
|
822
|
+
const calls: string[] = [];
|
|
823
|
+
const v064 = {
|
|
824
|
+
...MANIFEST,
|
|
825
|
+
version: "0.10.64",
|
|
826
|
+
digests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX, ops: D("c") },
|
|
827
|
+
};
|
|
828
|
+
const { deps, q, written } = setup({
|
|
829
|
+
meta: {
|
|
830
|
+
...META,
|
|
831
|
+
imageDigests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX },
|
|
832
|
+
releaseVersion: "0.10.64",
|
|
833
|
+
},
|
|
834
|
+
manifests: { "0.10.64": { ok: true, manifest: v064 } },
|
|
835
|
+
gcloud: [servingPlatform(ADMIN_AMD64, GATEWAY_AMD64)],
|
|
836
|
+
imageForms: createReleaseImageForms(fakeRegistryFetch(calls)),
|
|
837
|
+
});
|
|
838
|
+
expect((await runUpgraderJob(deps)).status).toBe("succeeded");
|
|
839
|
+
expect(written[0]).toMatchObject({
|
|
840
|
+
releaseVersion: "0.10.64",
|
|
841
|
+
imageDigests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX },
|
|
842
|
+
platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 },
|
|
843
|
+
});
|
|
844
|
+
expect(q.events.some((e) => e.message.startsWith("0.10.64 → 0.11.0"))).toBe(true);
|
|
845
|
+
// Each index read once, by digest, from the fixed mirror.
|
|
846
|
+
expect(calls.filter((c) => c.endsWith(`/admin/manifests/${ADMIN_INDEX}`))).toHaveLength(1);
|
|
847
|
+
expect(calls.filter((c) => c.endsWith(`/gateway/manifests/${GATEWAY_INDEX}`))).toHaveLength(
|
|
848
|
+
1,
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it("install.json with both forms matches directly: no drift, no lookup", async () => {
|
|
853
|
+
const { deps, order, q } = setup({
|
|
854
|
+
meta: { ...META, platformDigests: { admin: P("1"), gateway: P("2") } },
|
|
855
|
+
gcloud: [servingPlatform(P("1"), P("2"))],
|
|
856
|
+
});
|
|
857
|
+
expect((await runUpgraderJob(deps)).status).toBe("succeeded");
|
|
858
|
+
expect(order.filter((l) => l.startsWith("manifest"))).toEqual(["manifest 0.11.0"]);
|
|
859
|
+
// Only the target's index is read (to record its platform forms), never the serving one's.
|
|
860
|
+
expect(order.filter((l) => l.startsWith("index "))).toEqual([
|
|
861
|
+
`index admin@${D("a")}`,
|
|
862
|
+
`index gateway@${D("b")}`,
|
|
863
|
+
]);
|
|
864
|
+
expect(q.events.some((e) => e.message.includes("refreshed its install records"))).toBe(false);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("the roll records the new release's platform forms, signed ones first", async () => {
|
|
868
|
+
const signed = {
|
|
869
|
+
...MANIFEST,
|
|
870
|
+
platformDigests: { admin: P("x"), gateway: P("y"), ops: P("z") },
|
|
871
|
+
};
|
|
872
|
+
const { deps, order, written } = setup({ manifest: { ok: true, manifest: signed } });
|
|
873
|
+
expect((await runUpgraderJob(deps)).status).toBe("succeeded");
|
|
874
|
+
expect(written.at(-1)).toMatchObject({
|
|
875
|
+
releaseVersion: "0.11.0",
|
|
876
|
+
imageDigests: { admin: D("a"), gateway: D("b") },
|
|
877
|
+
platformDigests: { admin: P("x"), gateway: P("y") },
|
|
878
|
+
});
|
|
879
|
+
expect(order.some((l) => l.startsWith("index "))).toBe(false);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("a stale platform record is dropped when the platform forms cannot be read", async () => {
|
|
883
|
+
const { deps, q, written } = setup({
|
|
884
|
+
meta: { ...META, platformDigests: { admin: P("1"), gateway: P("2") } },
|
|
885
|
+
imageForms: "down",
|
|
886
|
+
});
|
|
887
|
+
expect((await runUpgraderJob(deps)).status).toBe("succeeded");
|
|
888
|
+
expect(written.at(-1)?.imageDigests).toEqual({ admin: D("a"), gateway: D("b") });
|
|
889
|
+
expect(written.at(-1)?.platformDigests).toBeUndefined();
|
|
890
|
+
expect(q.events.some((e) => e.level === "warn" && e.message.includes("linux/amd64"))).toBe(
|
|
891
|
+
true,
|
|
892
|
+
);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("a platform digest in no signed index is still refused with the current message", async () => {
|
|
896
|
+
const { deps, q, written } = setup({
|
|
897
|
+
gcloud: [servingPlatform(P("7"), P("6"))],
|
|
898
|
+
manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
|
|
899
|
+
});
|
|
900
|
+
expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
|
|
901
|
+
const end = q.end();
|
|
902
|
+
expect(end?.codes).toEqual(["install-state-drift"]);
|
|
903
|
+
expect(end?.reason).toContain("do not belong to one signed Caelo release");
|
|
904
|
+
expect(end?.reason).toContain(`admin ${P("7")}`);
|
|
905
|
+
expect(written).toEqual([]);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("an index the mirror cannot serve fails retryably, never as drift", async () => {
|
|
909
|
+
const { deps, q, written } = setup({
|
|
910
|
+
gcloud: [servingPlatform(P("1"), P("2"))],
|
|
911
|
+
manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
|
|
912
|
+
imageForms: "down",
|
|
913
|
+
});
|
|
914
|
+
expect((await runUpgraderJob(deps)).status).toBe("failed");
|
|
915
|
+
expect(q.end()?.codes).toEqual(["registry"]);
|
|
916
|
+
expect(q.end()?.reason).toContain("HTTP 503");
|
|
917
|
+
expect(written).toEqual([]);
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
768
921
|
it("regression #865: drift whose release manifest the registry cannot serve fails retryably, not install-state-drift", async () => {
|
|
769
922
|
const { deps, order, q, written } = setup({
|
|
770
923
|
meta: {
|