@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
package/src/install-state.ts
CHANGED
|
@@ -78,6 +78,16 @@ export interface InstallMetadata {
|
|
|
78
78
|
* installs that predate it.
|
|
79
79
|
*/
|
|
80
80
|
imageDigests?: ImageDigests;
|
|
81
|
+
/**
|
|
82
|
+
* #1025 — the linux/amd64 platform manifest inside each index of
|
|
83
|
+
* `imageDigests`: the form Cloud Run records for a serving revision
|
|
84
|
+
* (upgrade/image-forms.ts). Recorded next to the index digests from the
|
|
85
|
+
* signed release manifest or the content-checked index, so the upgrader
|
|
86
|
+
* matches the serving images without a registry lookup. Belongs to
|
|
87
|
+
* `imageDigests`: dropped whenever those change without it. Absent on
|
|
88
|
+
* installs recorded before it; the upgrader then reads the index.
|
|
89
|
+
*/
|
|
90
|
+
platformDigests?: ImageDigests;
|
|
81
91
|
/**
|
|
82
92
|
* #696 — the Caelo release (`0.11.0`) those digests belong to, recorded
|
|
83
93
|
* next to them by `upgrade` and the upgrader job. The upgrader job reads
|
|
@@ -182,11 +192,17 @@ export function resumedMetadata(
|
|
|
182
192
|
};
|
|
183
193
|
}
|
|
184
194
|
|
|
185
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Record the image digests an install now runs (see
|
|
197
|
+
* {@link InstallMetadata.imageDigests}).
|
|
198
|
+
*
|
|
199
|
+
* @param platformDigests - their linux/amd64 forms (#1025), when known
|
|
200
|
+
*/
|
|
186
201
|
export function recordImageDigests(
|
|
187
202
|
installId: string,
|
|
188
203
|
digests: ImageDigests,
|
|
189
204
|
releaseVersion?: string | null,
|
|
205
|
+
platformDigests?: ImageDigests | null,
|
|
190
206
|
): void {
|
|
191
207
|
const meta = readMetadata(installId);
|
|
192
208
|
if (!meta) throw new Error(`No install.json for install '${installId}'.`);
|
|
@@ -196,11 +212,14 @@ export function recordImageDigests(
|
|
|
196
212
|
// refuses on none and judges downgrades by it).
|
|
197
213
|
const same =
|
|
198
214
|
meta.imageDigests?.admin === digests.admin && meta.imageDigests?.gateway === digests.gateway;
|
|
199
|
-
const { releaseVersion: previous, ...rest } = meta;
|
|
215
|
+
const { releaseVersion: previous, platformDigests: previousPlatform, ...rest } = meta;
|
|
200
216
|
const version = releaseVersion ?? (same ? previous : undefined);
|
|
217
|
+
// The platform forms belong to their indexes just as the version does.
|
|
218
|
+
const platform = platformDigests ?? (same ? previousPlatform : undefined);
|
|
201
219
|
writeMetadata(installId, {
|
|
202
220
|
...rest,
|
|
203
221
|
imageDigests: { admin: digests.admin, gateway: digests.gateway },
|
|
222
|
+
...(platform ? { platformDigests: { admin: platform.admin, gateway: platform.gateway } } : {}),
|
|
204
223
|
...(version ? { releaseVersion: version } : {}),
|
|
205
224
|
});
|
|
206
225
|
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
provisionerServiceAccountEmail,
|
|
49
49
|
} from "./provisioner-identity.js";
|
|
50
50
|
import { assertRegionUnchanged } from "./regions.js";
|
|
51
|
+
import type { ReleaseManifest } from "./release-manifest.js";
|
|
51
52
|
import {
|
|
52
53
|
type HttpFetch,
|
|
53
54
|
ROTATABLE_SECRETS,
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
|
|
77
78
|
import { findServiceUrl, lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
|
|
78
79
|
import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
|
|
80
|
+
import { createReleaseImageForms, platformDigestsOf } from "./upgrade/image-forms.js";
|
|
79
81
|
import { recordRolledDigests } from "./upgrade/record-rolled.js";
|
|
80
82
|
import {
|
|
81
83
|
canonicalReleaseTag,
|
|
@@ -729,20 +731,35 @@ async function gcpHealthProbe(
|
|
|
729
731
|
* install's install.json and Pulumi stack. The converge job (#819,
|
|
730
732
|
* converge-job-gcp.ts) records through the same function as the terminal.
|
|
731
733
|
*
|
|
734
|
+
* @param manifest - the verified manifest of the rolled release, when the
|
|
735
|
+
* caller holds it: its signed platform digests save the index lookup
|
|
732
736
|
* @returns whether every record was written; false fails the upgrade.
|
|
733
737
|
*/
|
|
734
|
-
export function recordRolledRelease(
|
|
738
|
+
export async function recordRolledRelease(
|
|
735
739
|
installId: string,
|
|
736
740
|
provider: "gcp" | "gcp-firebase",
|
|
737
741
|
digests: ImageDigests,
|
|
738
742
|
releaseVersion: string | null,
|
|
743
|
+
manifest?: ReleaseManifest | null,
|
|
739
744
|
): Promise<boolean> {
|
|
740
745
|
// The local copy always names where the state lives (install-state.ts
|
|
741
746
|
// stateStoreFor), and reading it cannot fail on bucket access.
|
|
742
747
|
const local = readLocalMetadata(installId);
|
|
743
748
|
const bucket = local?.stateBackend?.kind === "gcs" ? local.stateBackend.bucket : null;
|
|
749
|
+
// #1025 — record the linux/amd64 forms Cloud Run reports next to the
|
|
750
|
+
// indexes. Not knowing them is no failed upgrade: the upgrader job then
|
|
751
|
+
// reads the index from the release registry itself.
|
|
752
|
+
const platform = await platformDigestsOf(digests, createReleaseImageForms(), manifest);
|
|
753
|
+
if (!platform.ok) {
|
|
754
|
+
log.warn(
|
|
755
|
+
yellow(
|
|
756
|
+
`install.json records the release by its image indexes only; their linux/amd64 forms could not be read (${platform.message}).`,
|
|
757
|
+
),
|
|
758
|
+
);
|
|
759
|
+
}
|
|
744
760
|
return recordRolledDigests(digests, releaseVersion, {
|
|
745
|
-
recordInstall: (d, v) =>
|
|
761
|
+
recordInstall: (d, v) =>
|
|
762
|
+
recordImageDigests(installId, d, v, platform.ok ? platform.platformDigests : null),
|
|
746
763
|
pinStack: async (d) => {
|
|
747
764
|
const meta = readMetadata(installId);
|
|
748
765
|
if (!meta) throw new Error(`no install.json for '${installId}'`);
|
|
@@ -230,6 +230,16 @@ describe("ReleaseManifestSchema", () => {
|
|
|
230
230
|
const ghsa = { ...MANIFEST, security: true, advisories: ["GHSA-2c2m-4x8q-wx9f"] };
|
|
231
231
|
expect(ReleaseManifestSchema.safeParse(ghsa).success).toBe(true);
|
|
232
232
|
});
|
|
233
|
+
|
|
234
|
+
it("#1025: platformDigests is optional, and must name all three images when present", () => {
|
|
235
|
+
// The canonical fixture has none: manifests signed before the field stay valid.
|
|
236
|
+
expect("platformDigests" in MANIFEST).toBe(false);
|
|
237
|
+
const platformDigests = { admin: d("1"), gateway: d("2"), ops: d("3") };
|
|
238
|
+
const parsed = ReleaseManifestSchema.safeParse({ ...MANIFEST, platformDigests });
|
|
239
|
+
expect(parsed.success && parsed.data.platformDigests).toEqual(platformDigests);
|
|
240
|
+
const partial = { ...MANIFEST, platformDigests: { admin: d("1"), gateway: d("2") } };
|
|
241
|
+
expect(ReleaseManifestSchema.safeParse(partial).success).toBe(false);
|
|
242
|
+
});
|
|
233
243
|
});
|
|
234
244
|
|
|
235
245
|
describe("compareImageDigests", () => {
|
|
@@ -241,8 +251,23 @@ describe("compareImageDigests", () => {
|
|
|
241
251
|
|
|
242
252
|
it("names each mismatching or unresolved image, ignores absent ones", () => {
|
|
243
253
|
expect(compareImageDigests(MANIFEST, { admin: d("f"), gateway: null })).toEqual([
|
|
244
|
-
{ image: "admin", expected: d("a"), actual: d("f") },
|
|
245
|
-
{ image: "gateway", expected: d("b"), actual: null },
|
|
254
|
+
{ image: "admin", form: "index", expected: d("a"), actual: d("f") },
|
|
255
|
+
{ image: "gateway", form: "index", expected: d("b"), actual: null },
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("#1025: compares the linux/amd64 platform digests the manifest signs", () => {
|
|
260
|
+
const withPlatform = {
|
|
261
|
+
...MANIFEST,
|
|
262
|
+
platformDigests: { admin: d("1"), gateway: d("2"), ops: d("3") },
|
|
263
|
+
};
|
|
264
|
+
expect(compareImageDigests(withPlatform, {}, { admin: d("1"), ops: d("3") })).toEqual([]);
|
|
265
|
+
expect(compareImageDigests(withPlatform, {}, { gateway: d("9") })).toEqual([
|
|
266
|
+
{ image: "gateway", form: "platform", expected: d("2"), actual: d("9") },
|
|
267
|
+
]);
|
|
268
|
+
// A manifest without the field cannot vouch for a platform digest.
|
|
269
|
+
expect(compareImageDigests(MANIFEST, {}, { admin: d("1") })).toEqual([
|
|
270
|
+
{ image: "admin", form: "platform", expected: null, actual: d("1") },
|
|
246
271
|
]);
|
|
247
272
|
});
|
|
248
273
|
});
|
package/src/release-manifest.ts
CHANGED
|
@@ -221,27 +221,40 @@ export async function fetchVerifiedReleaseManifest(
|
|
|
221
221
|
/** One image whose resolved digest differs from the manifest's. */
|
|
222
222
|
export interface DigestMismatch {
|
|
223
223
|
readonly image: ReleaseImage;
|
|
224
|
-
|
|
224
|
+
/** Which digest differs: the image index, or its linux/amd64 manifest (#1025). */
|
|
225
|
+
readonly form: "index" | "platform";
|
|
226
|
+
/** The manifest's digest; null when the manifest has no platform digests. */
|
|
227
|
+
readonly expected: string | null;
|
|
225
228
|
readonly actual: string | null;
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
/**
|
|
229
232
|
* Compare the digests a registry resolved for the release's tags with the
|
|
230
|
-
* signed manifest. Only images present in `resolved`
|
|
231
|
-
* that an explicitly `null` entry (tag did not
|
|
233
|
+
* signed manifest. Only images present in `resolved` (or `resolvedPlatform`)
|
|
234
|
+
* are compared, except that an explicitly `null` entry (tag did not
|
|
235
|
+
* resolve) is a mismatch.
|
|
232
236
|
*
|
|
237
|
+
* @param resolvedPlatform - each index's linux/amd64 manifest, compared
|
|
238
|
+
* with the manifest's `platformDigests` (#1025)
|
|
233
239
|
* @returns every mismatch; empty means the registry serves the signed images.
|
|
234
240
|
*/
|
|
235
241
|
export function compareImageDigests(
|
|
236
242
|
manifest: ReleaseManifest,
|
|
237
243
|
resolved: Partial<Record<ReleaseImage, string | null>>,
|
|
244
|
+
resolvedPlatform: Partial<Record<ReleaseImage, string | null>> = {},
|
|
238
245
|
): DigestMismatch[] {
|
|
239
246
|
const out: DigestMismatch[] = [];
|
|
240
247
|
for (const image of RELEASE_IMAGES) {
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
248
|
+
if (image in resolved) {
|
|
249
|
+
const actual = resolved[image] ?? null;
|
|
250
|
+
if (actual !== manifest.digests[image]) {
|
|
251
|
+
out.push({ image, form: "index", expected: manifest.digests[image], actual });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (image in resolvedPlatform) {
|
|
255
|
+
const actual = resolvedPlatform[image] ?? null;
|
|
256
|
+
const expected = manifest.platformDigests?.[image] ?? null;
|
|
257
|
+
if (actual !== expected) out.push({ image, form: "platform", expected, actual });
|
|
245
258
|
}
|
|
246
259
|
}
|
|
247
260
|
return out;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
4
|
+
"manifests": [
|
|
5
|
+
{
|
|
6
|
+
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
7
|
+
"digest": "sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656",
|
|
8
|
+
"size": 4673,
|
|
9
|
+
"platform": {
|
|
10
|
+
"architecture": "amd64",
|
|
11
|
+
"os": "linux"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
16
|
+
"digest": "sha256:06f9defdb92e8252e9faab8fcb8fb340d3949d5cdec09e06b42905ade7ba30c0",
|
|
17
|
+
"size": 1113,
|
|
18
|
+
"annotations": {
|
|
19
|
+
"vnd.docker.reference.digest": "sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656",
|
|
20
|
+
"vnd.docker.reference.type": "attestation-manifest"
|
|
21
|
+
},
|
|
22
|
+
"platform": {
|
|
23
|
+
"architecture": "unknown",
|
|
24
|
+
"os": "unknown"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
4
|
+
"manifests": [
|
|
5
|
+
{
|
|
6
|
+
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
7
|
+
"digest": "sha256:d24f2675d43e7891f217938c74475e9c31a64ffda0c96cf4fbb0a3f73e31367e",
|
|
8
|
+
"size": 3527,
|
|
9
|
+
"platform": {
|
|
10
|
+
"architecture": "amd64",
|
|
11
|
+
"os": "linux"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
16
|
+
"digest": "sha256:a5a3180c2f8e971ad8650026b1e9734f2d7289d74c12179e194b8d48380e2738",
|
|
17
|
+
"size": 1113,
|
|
18
|
+
"annotations": {
|
|
19
|
+
"vnd.docker.reference.digest": "sha256:d24f2675d43e7891f217938c74475e9c31a64ffda0c96cf4fbb0a3f73e31367e",
|
|
20
|
+
"vnd.docker.reference.type": "attestation-manifest"
|
|
21
|
+
},
|
|
22
|
+
"platform": {
|
|
23
|
+
"architecture": "unknown",
|
|
24
|
+
"os": "unknown"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The v0.10.64 admin + gateway image indexes exactly as the release mirror
|
|
5
|
+
* serves them (fixtures/, byte for byte), and a fake `fetch` that serves
|
|
6
|
+
* them by digest — the shapes of #1025: each index lists its linux/amd64
|
|
7
|
+
* manifest plus one buildx attestation manifest, and Cloud Run records the
|
|
8
|
+
* linux/amd64 digest for a serving revision.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const fixture = (name: string): Uint8Array<ArrayBuffer> =>
|
|
15
|
+
new Uint8Array(readFileSync(join(import.meta.dir, "fixtures", name)));
|
|
16
|
+
|
|
17
|
+
/** v0.10.64 admin: the index the signed manifest names, and what it lists. */
|
|
18
|
+
export const ADMIN_INDEX =
|
|
19
|
+
"sha256:bcda1725a5390c910c56cfedb6f55bc4f38cebb0231e9e9b4297bb5c08aec3e0";
|
|
20
|
+
export const ADMIN_AMD64 =
|
|
21
|
+
"sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656";
|
|
22
|
+
export const ADMIN_ATTESTATION =
|
|
23
|
+
"sha256:06f9defdb92e8252e9faab8fcb8fb340d3949d5cdec09e06b42905ade7ba30c0";
|
|
24
|
+
/** v0.10.64 gateway. */
|
|
25
|
+
export const GATEWAY_INDEX =
|
|
26
|
+
"sha256:fbb0be7a106f75c25e44bca1898fd56af27d11fa599659afe55e4f6ffaeca4a3";
|
|
27
|
+
export const GATEWAY_AMD64 =
|
|
28
|
+
"sha256:d24f2675d43e7891f217938c74475e9c31a64ffda0c96cf4fbb0a3f73e31367e";
|
|
29
|
+
|
|
30
|
+
/** The index bytes, by digest. */
|
|
31
|
+
export const LIVE_INDEXES: Readonly<Record<string, Uint8Array<ArrayBuffer>>> = {
|
|
32
|
+
[ADMIN_INDEX]: fixture("admin-0.10.64-index.json"),
|
|
33
|
+
[GATEWAY_INDEX]: fixture("gateway-0.10.64-index.json"),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** How the fake registry answers. */
|
|
37
|
+
export interface FakeRegistryOptions {
|
|
38
|
+
/** Answer every request with this HTTP status instead. */
|
|
39
|
+
readonly status?: number;
|
|
40
|
+
/** Throw (network failure) instead of answering. */
|
|
41
|
+
readonly unreachable?: boolean;
|
|
42
|
+
/** Serve these bytes for every digest (a tampering registry). */
|
|
43
|
+
readonly tampered?: Uint8Array<ArrayBuffer>;
|
|
44
|
+
/** Index bytes to serve besides {@link LIVE_INDEXES}. */
|
|
45
|
+
readonly extra?: Readonly<Record<string, Uint8Array<ArrayBuffer>>>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A `fetch` serving the mirror's `/v2/<repo>/manifests/<digest>`; records each URL in `calls`. */
|
|
49
|
+
export function fakeRegistryFetch(calls: string[], opts: FakeRegistryOptions = {}): typeof fetch {
|
|
50
|
+
const impl = async (input: string | URL | Request): Promise<Response> => {
|
|
51
|
+
const url = String(input instanceof Request ? input.url : input);
|
|
52
|
+
calls.push(url);
|
|
53
|
+
if (opts.unreachable) throw new Error("getaddrinfo ENOTFOUND");
|
|
54
|
+
if (opts.status) return new Response("", { status: opts.status });
|
|
55
|
+
const digest = url.split("/manifests/")[1] ?? "";
|
|
56
|
+
const bytes = opts.tampered ?? opts.extra?.[digest] ?? LIVE_INDEXES[digest];
|
|
57
|
+
if (!bytes) return new Response("", { status: 404 });
|
|
58
|
+
return new Response(bytes, { status: 200 });
|
|
59
|
+
};
|
|
60
|
+
return impl as typeof fetch;
|
|
61
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* findServingImage against a fake gcloud
|
|
5
|
-
*
|
|
6
|
-
* platform manifest inside it (#860)
|
|
7
|
-
*
|
|
4
|
+
* findServingImage against a fake gcloud. A revision's spec and
|
|
5
|
+
* `status.imageDigest` may name the release's index digest or the
|
|
6
|
+
* platform manifest inside it (#860); on live installs BOTH name the
|
|
7
|
+
* platform manifest (#1025), and only the service template keeps the index
|
|
8
|
+
* (findTemplateImageDigest). Every digest found must come back.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { describe, expect, it } from "bun:test";
|
|
@@ -12,6 +13,7 @@ import type { GcloudResult } from "../gcloud.js";
|
|
|
12
13
|
import type { GcloudRunner } from "../gcloud-retry.js";
|
|
13
14
|
import {
|
|
14
15
|
findServingImage,
|
|
16
|
+
findTemplateImageDigest,
|
|
15
17
|
lookupFailure,
|
|
16
18
|
resolveGcpResourceName,
|
|
17
19
|
servingImageDigests,
|
|
@@ -108,15 +110,15 @@ const revision = (image: string, imageDigest?: string) =>
|
|
|
108
110
|
ok(JSON.stringify({ spec: { containers: [{ image }] }, status: { imageDigest } }));
|
|
109
111
|
|
|
110
112
|
describe("findServingImage", () => {
|
|
111
|
-
it("returns the
|
|
113
|
+
it("returns the spec's index digest and the platform digest Cloud Run resolved", async () => {
|
|
112
114
|
const image = await findServingImage(
|
|
113
115
|
gcloud(revision(`${REPO}@${INDEX}`, `${REPO}@${PLATFORM}`)),
|
|
114
116
|
"acme",
|
|
115
117
|
"europe-west1",
|
|
116
118
|
"caelo-production-admin-x",
|
|
117
119
|
);
|
|
118
|
-
expect(image).toEqual({
|
|
119
|
-
expect(servingImageDigests(image ?? {
|
|
120
|
+
expect(image).toEqual({ specDigest: INDEX, resolvedDigest: PLATFORM });
|
|
121
|
+
expect(servingImageDigests(image ?? { specDigest: null, resolvedDigest: null })).toEqual([
|
|
120
122
|
INDEX,
|
|
121
123
|
PLATFORM,
|
|
122
124
|
]);
|
|
@@ -129,11 +131,26 @@ describe("findServingImage", () => {
|
|
|
129
131
|
"europe-west1",
|
|
130
132
|
"svc",
|
|
131
133
|
);
|
|
132
|
-
expect(image).toEqual({
|
|
134
|
+
expect(image).toEqual({ specDigest: null, resolvedDigest: PLATFORM });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("regression #1025: the live shape, spec and status both name the platform manifest", async () => {
|
|
138
|
+
// caelo-production-admin-482044c-00015-2vc on searchviu-website, deployed
|
|
139
|
+
// as the v0.10.64 index admin@sha256:bcda… (the service template).
|
|
140
|
+
const live = `${REPO}@sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656`;
|
|
141
|
+
const image = await findServingImage(
|
|
142
|
+
gcloud(revision(live, live)),
|
|
143
|
+
"acme",
|
|
144
|
+
"europe-west1",
|
|
145
|
+
"svc",
|
|
146
|
+
);
|
|
147
|
+
expect(servingImageDigests(image ?? { specDigest: null, resolvedDigest: null })).toEqual([
|
|
148
|
+
"sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656",
|
|
149
|
+
]);
|
|
133
150
|
});
|
|
134
151
|
|
|
135
152
|
it("one digest when both name the same image", () => {
|
|
136
|
-
expect(servingImageDigests({
|
|
153
|
+
expect(servingImageDigests({ specDigest: INDEX, resolvedDigest: INDEX })).toEqual([INDEX]);
|
|
137
154
|
});
|
|
138
155
|
|
|
139
156
|
it("null when no digest can be read or the output is not JSON", async () => {
|
|
@@ -157,3 +174,30 @@ describe("findServingImage", () => {
|
|
|
157
174
|
).toBeNull();
|
|
158
175
|
});
|
|
159
176
|
});
|
|
177
|
+
|
|
178
|
+
describe("findTemplateImageDigest (#1025)", () => {
|
|
179
|
+
const service = (image: unknown) =>
|
|
180
|
+
ok(JSON.stringify({ spec: { template: { spec: { containers: [{ image }] } } } }));
|
|
181
|
+
const runner =
|
|
182
|
+
(r: GcloudResult): GcloudRunner =>
|
|
183
|
+
async () =>
|
|
184
|
+
r;
|
|
185
|
+
|
|
186
|
+
it("reads the index digest the template pins", async () => {
|
|
187
|
+
expect(
|
|
188
|
+
await findTemplateImageDigest(runner(service(`${REPO}@${INDEX}`)), "acme", "r", "svc"),
|
|
189
|
+
).toBe(INDEX);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("null for a tag, an odd shape, bad JSON or a failed describe", async () => {
|
|
193
|
+
for (const r of [
|
|
194
|
+
service(`${REPO}:latest`),
|
|
195
|
+
service(7),
|
|
196
|
+
ok("null"),
|
|
197
|
+
ok("not json"),
|
|
198
|
+
fail("denied"),
|
|
199
|
+
]) {
|
|
200
|
+
expect(await findTemplateImageDigest(runner(r), "acme", "r", "svc")).toBeNull();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -157,28 +157,30 @@ export async function findCurrentRevision(
|
|
|
157
157
|
* by (not the service template's: after a rollback the template names the
|
|
158
158
|
* failed image while traffic stays on the previous revision).
|
|
159
159
|
*
|
|
160
|
-
*
|
|
161
|
-
* (buildx adds a provenance/SBOM attestation
|
|
162
|
-
* single-platform build), and the signed release
|
|
163
|
-
* and every deploy pin the index digest. Cloud Run
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* (
|
|
168
|
-
*
|
|
169
|
-
*
|
|
160
|
+
* Which form these digests take (#860, corrected by #1025): Caelo's release
|
|
161
|
+
* images are OCI image *indexes* (buildx adds a provenance/SBOM attestation
|
|
162
|
+
* manifest even to a single-platform build), and the signed release
|
|
163
|
+
* manifest, install.json and every deploy pin the index digest. Cloud Run
|
|
164
|
+
* resolves that index to its linux/amd64 *platform manifest* and records
|
|
165
|
+
* the platform digest — in `status.imageDigest` AND, contrary to what #860
|
|
166
|
+
* assumed, in the revision's `spec.containers[0].image` too: a revision
|
|
167
|
+
* deployed as `admin@sha256:bcda…` (the v0.10.64 index) reads back as
|
|
168
|
+
* `admin@sha256:2e75…` in both fields (live installs, 2026-10-23). Only the
|
|
169
|
+
* service template keeps the index. So these digests are matched against a
|
|
170
|
+
* release's index AND the platform manifests inside it
|
|
171
|
+
* (upgrade/image-forms.ts), never against the index alone.
|
|
170
172
|
*/
|
|
171
173
|
export interface ServingImage {
|
|
172
|
-
/** The digest the revision
|
|
173
|
-
readonly
|
|
174
|
+
/** The digest the revision's `spec.containers[0].image` names; null when it names a tag. */
|
|
175
|
+
readonly specDigest: string | null;
|
|
174
176
|
/** The digest Cloud Run resolved and runs (`status.imageDigest`); null when not reported. */
|
|
175
177
|
readonly resolvedDigest: string | null;
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
/** Every digest `image` is known by,
|
|
180
|
+
/** Every digest `image` is known by, spec form first. */
|
|
179
181
|
export function servingImageDigests(image: ServingImage): string[] {
|
|
180
182
|
const out: string[] = [];
|
|
181
|
-
for (const d of [image.
|
|
183
|
+
for (const d of [image.specDigest, image.resolvedDigest]) {
|
|
182
184
|
if (d && !out.includes(d)) out.push(d);
|
|
183
185
|
}
|
|
184
186
|
return out;
|
|
@@ -231,12 +233,55 @@ export async function findServingImage(
|
|
|
231
233
|
const digestOf = (ref: string | undefined): string | null =>
|
|
232
234
|
ref === undefined ? null : (PINNED_DIGEST.exec(ref.trim())?.[1] ?? null);
|
|
233
235
|
const image: ServingImage = {
|
|
234
|
-
|
|
236
|
+
specDigest: digestOf(parsed.data.spec?.containers?.[0]?.image),
|
|
235
237
|
resolvedDigest: digestOf(parsed.data.status?.imageDigest),
|
|
236
238
|
};
|
|
237
239
|
return servingImageDigests(image).length > 0 ? image : null;
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
/** The fields of `gcloud run services describe --format=json` {@link findTemplateImageDigest} reads. */
|
|
243
|
+
const ServiceJsonSchema = z.object({
|
|
244
|
+
spec: z
|
|
245
|
+
.object({
|
|
246
|
+
template: z
|
|
247
|
+
.object({
|
|
248
|
+
spec: z
|
|
249
|
+
.object({
|
|
250
|
+
containers: z.array(z.object({ image: z.string().optional() })).optional(),
|
|
251
|
+
})
|
|
252
|
+
.optional(),
|
|
253
|
+
})
|
|
254
|
+
.optional(),
|
|
255
|
+
})
|
|
256
|
+
.optional(),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The digest the service's template pins (the index a deploy named, #1025),
|
|
261
|
+
* or null when it cannot be read or names a tag. NOT what serves — after a
|
|
262
|
+
* rollback the template names the failed image — so only a hint for which
|
|
263
|
+
* release to check (upgrader-records.ts).
|
|
264
|
+
*/
|
|
265
|
+
export async function findTemplateImageDigest(
|
|
266
|
+
run: GcloudRunner,
|
|
267
|
+
projectId: string,
|
|
268
|
+
region: string,
|
|
269
|
+
serviceName: string,
|
|
270
|
+
): Promise<string | null> {
|
|
271
|
+
const stdout = await describeServiceJson(run, projectId, region, serviceName);
|
|
272
|
+
if (stdout === null) return null;
|
|
273
|
+
let json: unknown;
|
|
274
|
+
try {
|
|
275
|
+
json = JSON.parse(stdout);
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const parsed = ServiceJsonSchema.safeParse(json);
|
|
280
|
+
if (!parsed.success) return null;
|
|
281
|
+
const ref = parsed.data.spec?.template?.spec?.containers?.[0]?.image;
|
|
282
|
+
return ref === undefined ? null : (PINNED_DIGEST.exec(ref.trim())?.[1] ?? null);
|
|
283
|
+
}
|
|
284
|
+
|
|
240
285
|
/** The service's own `run.app` URL. */
|
|
241
286
|
export async function findServiceUrl(
|
|
242
287
|
run: GcloudRunner,
|