@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/upgrader-job.ts
CHANGED
|
@@ -70,11 +70,13 @@ import {
|
|
|
70
70
|
import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
|
|
71
71
|
import {
|
|
72
72
|
findServingImage,
|
|
73
|
+
findTemplateImageDigest,
|
|
73
74
|
lookupFailure,
|
|
74
75
|
resolveGcpResourceName,
|
|
75
76
|
servingImageDigests,
|
|
76
77
|
} from "./upgrade/gcp-lookup.js";
|
|
77
78
|
import type { HealthProbe } from "./upgrade/health.js";
|
|
79
|
+
import { platformDigestsOf, type ReleaseImageForms } from "./upgrade/image-forms.js";
|
|
78
80
|
import { releaseImageRef } from "./upgrade/release-images.js";
|
|
79
81
|
import { assessUpgradeRequest, type RefusalCode } from "./upgrader-assess.js";
|
|
80
82
|
import {
|
|
@@ -166,6 +168,13 @@ export interface UpgraderJobDeps {
|
|
|
166
168
|
readonly iam: IamTester;
|
|
167
169
|
/** fetchVerifiedReleaseManifest against the fixed registry. */
|
|
168
170
|
readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
|
|
171
|
+
/**
|
|
172
|
+
* Every digest a release image is known by (#1025): the signed platform
|
|
173
|
+
* digests, else the content-checked index from the fixed mirror
|
|
174
|
+
* (createReleaseImageForms). One instance per run, so each index is read
|
|
175
|
+
* at most once.
|
|
176
|
+
*/
|
|
177
|
+
readonly imageForms: ReleaseImageForms;
|
|
169
178
|
/**
|
|
170
179
|
* Take the install's state lock (the one `pulumi up`, `state migrate` and
|
|
171
180
|
* the CLI `upgrade` hold) right before the run's first write; returns its
|
|
@@ -384,10 +393,16 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
384
393
|
if (!serving.ok) {
|
|
385
394
|
return stopWith("failed", ["lookup"], `Nothing was changed: ${serving.error}.`);
|
|
386
395
|
}
|
|
387
|
-
if (!recordMatchesServing(meta
|
|
396
|
+
if (!recordMatchesServing(meta, serving.digests)) {
|
|
388
397
|
// #860 — re-record the serving release ourselves, but only one whose
|
|
389
|
-
// admin AND gateway images a signed release manifest names
|
|
390
|
-
|
|
398
|
+
// admin AND gateway images a signed release manifest names, as its
|
|
399
|
+
// index or a platform manifest inside it (#1025).
|
|
400
|
+
const found = await findServingRelease(meta, serving.digests, {
|
|
401
|
+
run: deps.run,
|
|
402
|
+
fetchManifest: deps.fetchManifest,
|
|
403
|
+
imageForms: deps.imageForms,
|
|
404
|
+
hintDigests: () => templateDigests(deps, serving.services),
|
|
405
|
+
});
|
|
391
406
|
if (!found.ok) {
|
|
392
407
|
if (found.retryable) return stopWith("failed", ["registry"], found.reason);
|
|
393
408
|
// #815 — the serving images were read without the lock: a run that
|
|
@@ -400,14 +415,17 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
400
415
|
}
|
|
401
416
|
return stopWith("needs_cloud_shell", ["install-state-drift"], found.reason);
|
|
402
417
|
}
|
|
403
|
-
// The manifest's digests, not the serving ones: the signed form
|
|
404
|
-
// one every
|
|
418
|
+
// The manifest's index digests, not the serving ones: the signed form
|
|
419
|
+
// is the one every deploy pins. The linux/amd64 forms go next to them
|
|
420
|
+
// (#1025), so the next run matches what Cloud Run records directly.
|
|
421
|
+
const { platformDigests: _stale, ...rest } = meta;
|
|
405
422
|
meta = {
|
|
406
|
-
...
|
|
423
|
+
...rest,
|
|
407
424
|
imageDigests: {
|
|
408
425
|
admin: found.manifest.digests.admin,
|
|
409
426
|
gateway: found.manifest.digests.gateway,
|
|
410
427
|
},
|
|
428
|
+
...(found.platformDigests ? { platformDigests: found.platformDigests } : {}),
|
|
411
429
|
releaseVersion: found.manifest.version,
|
|
412
430
|
};
|
|
413
431
|
// Judged against now, written under the lock once decided (#815).
|
|
@@ -562,9 +580,23 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
562
580
|
// never "succeeded" while the records name the previous release).
|
|
563
581
|
recordDigests: async (digests) => {
|
|
564
582
|
r.phase("record");
|
|
583
|
+
// #1025 — both forms, so the next run recognises what Cloud Run
|
|
584
|
+
// records without a registry lookup. Not knowing the platform form
|
|
585
|
+
// is no failure: the next run then reads the index itself.
|
|
586
|
+
const platform = await platformDigestsOf(digests, deps.imageForms, m);
|
|
587
|
+
if (!platform.ok) {
|
|
588
|
+
r.warn(
|
|
589
|
+
`install.json records ${m.version} by its image indexes only; their linux/amd64 forms could not be read (${platform.message}). The next update reads them from the release registry.`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
565
592
|
try {
|
|
566
|
-
const fresh = deps.readInstall() ?? meta;
|
|
567
|
-
deps.writeInstall({
|
|
593
|
+
const { platformDigests: _previous, ...fresh } = deps.readInstall() ?? meta;
|
|
594
|
+
deps.writeInstall({
|
|
595
|
+
...fresh,
|
|
596
|
+
imageDigests: digests,
|
|
597
|
+
...(platform.ok ? { platformDigests: platform.platformDigests } : {}),
|
|
598
|
+
releaseVersion: m.version,
|
|
599
|
+
});
|
|
568
600
|
r.info(`Recorded ${m.version} as this install's release.`);
|
|
569
601
|
} catch (e) {
|
|
570
602
|
recordError = errorText(e);
|
|
@@ -659,10 +691,17 @@ export async function recordUpdateEnd(
|
|
|
659
691
|
}
|
|
660
692
|
|
|
661
693
|
/** Every digest the revisions serving traffic are known by, per service. */
|
|
662
|
-
async function servingDigests(
|
|
663
|
-
|
|
664
|
-
|
|
694
|
+
async function servingDigests(deps: UpgraderJobDeps): Promise<
|
|
695
|
+
| {
|
|
696
|
+
ok: true;
|
|
697
|
+
digests: ServingDigests;
|
|
698
|
+
/** The services' deployed names, for the template hints. */
|
|
699
|
+
services: Readonly<Record<RollSlug, string>>;
|
|
700
|
+
}
|
|
701
|
+
| { ok: false; error: string }
|
|
702
|
+
> {
|
|
665
703
|
const digests: Partial<Record<RollSlug, readonly string[]>> = {};
|
|
704
|
+
const services: Partial<Record<RollSlug, string>> = {};
|
|
666
705
|
for (const slug of ["admin", "gateway"] as const) {
|
|
667
706
|
const prefix = `${gcpNamePrefix(deps.env)}-${slug}`;
|
|
668
707
|
const service = await resolveGcpResourceName(
|
|
@@ -678,8 +717,30 @@ async function servingDigests(
|
|
|
678
717
|
const image = await findServingImage(deps.run, deps.projectId, deps.region, service.name);
|
|
679
718
|
if (!image) return { ok: false, error: `could not read which image the ${slug} serves` };
|
|
680
719
|
digests[slug] = servingImageDigests(image);
|
|
720
|
+
services[slug] = service.name;
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
digests: digests as ServingDigests,
|
|
725
|
+
services: services as Record<RollSlug, string>,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The index digests the services' templates pin (#1025): hints for which
|
|
731
|
+
* release to check when the serving revisions name only platform digests,
|
|
732
|
+
* which carry no release tag.
|
|
733
|
+
*/
|
|
734
|
+
async function templateDigests(
|
|
735
|
+
deps: UpgraderJobDeps,
|
|
736
|
+
services: Readonly<Record<RollSlug, string>>,
|
|
737
|
+
): Promise<ServingDigests> {
|
|
738
|
+
const out: Partial<Record<RollSlug, readonly string[]>> = {};
|
|
739
|
+
for (const slug of ["admin", "gateway"] as const) {
|
|
740
|
+
const d = await findTemplateImageDigest(deps.run, deps.projectId, deps.region, services[slug]);
|
|
741
|
+
out[slug] = d ? [d] : [];
|
|
681
742
|
}
|
|
682
|
-
return
|
|
743
|
+
return out as ServingDigests;
|
|
683
744
|
}
|
|
684
745
|
|
|
685
746
|
async function recordFacts(
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which signed release serves (#860, #1025), with the shapes recorded on
|
|
5
|
+
* live installs: the serving revisions name the linux/amd64 platform
|
|
6
|
+
* manifests (`2e75…`, `d24f…`), the signed v0.10.64 manifest and
|
|
7
|
+
* install.json the indexes (`bcda…`, `fbb0…`) that list them next to one
|
|
8
|
+
* attestation manifest each.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
13
|
+
import type { InstallMetadata } from "./install-state.js";
|
|
14
|
+
import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
|
|
15
|
+
import {
|
|
16
|
+
ADMIN_AMD64,
|
|
17
|
+
ADMIN_ATTESTATION,
|
|
18
|
+
ADMIN_INDEX,
|
|
19
|
+
fakeRegistryFetch,
|
|
20
|
+
GATEWAY_AMD64,
|
|
21
|
+
GATEWAY_INDEX,
|
|
22
|
+
} from "./upgrade/__tests__/live-indexes.js";
|
|
23
|
+
import { createReleaseImageForms } from "./upgrade/image-forms.js";
|
|
24
|
+
import {
|
|
25
|
+
findServingRelease,
|
|
26
|
+
recordMatchesServing,
|
|
27
|
+
type ServingDigests,
|
|
28
|
+
type ServingReleaseDeps,
|
|
29
|
+
} from "./upgrader-records.js";
|
|
30
|
+
|
|
31
|
+
const D = (c: string) => `sha256:${c.repeat(64)}`;
|
|
32
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
33
|
+
|
|
34
|
+
const V064: ReleaseManifest = {
|
|
35
|
+
manifestVersion: 1,
|
|
36
|
+
version: "0.10.64",
|
|
37
|
+
channel: "stable",
|
|
38
|
+
digests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX, ops: D("c") },
|
|
39
|
+
minFromVersion: "0.10.0",
|
|
40
|
+
infraContractVersion: 13,
|
|
41
|
+
migrations: { contract: false, lastContractVersion: null, head: "0338_x" },
|
|
42
|
+
security: false,
|
|
43
|
+
advisories: [],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const META: InstallMetadata = {
|
|
47
|
+
installId: "gcp-firebase-acme",
|
|
48
|
+
provider: "gcp-firebase",
|
|
49
|
+
projectId: "acme",
|
|
50
|
+
domain: "acme.com",
|
|
51
|
+
ownerEmail: "owner@acme.com",
|
|
52
|
+
region: "europe-west1",
|
|
53
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
54
|
+
imageDigests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX },
|
|
55
|
+
releaseVersion: "0.10.64",
|
|
56
|
+
infraContractVersion: 13,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** What the serving revisions read back as on searchviu-website (spec = status). */
|
|
60
|
+
const LIVE_SERVING: ServingDigests = { admin: [ADMIN_AMD64], gateway: [GATEWAY_AMD64] };
|
|
61
|
+
|
|
62
|
+
function deps(
|
|
63
|
+
opts: {
|
|
64
|
+
manifests?: Record<string, ReleaseManifest>;
|
|
65
|
+
calls?: string[];
|
|
66
|
+
registry?: Parameters<typeof fakeRegistryFetch>[1];
|
|
67
|
+
/** `gcloud artifacts docker tags list` answers: version → digest, per slug. */
|
|
68
|
+
tags?: Partial<Record<"admin" | "gateway", Record<string, string>>>;
|
|
69
|
+
hints?: ServingDigests;
|
|
70
|
+
} = {},
|
|
71
|
+
): ServingReleaseDeps & { fetched: string[] } {
|
|
72
|
+
const fetched: string[] = [];
|
|
73
|
+
const manifests = opts.manifests ?? { "0.10.64": V064 };
|
|
74
|
+
return {
|
|
75
|
+
fetched,
|
|
76
|
+
run: async (args) => {
|
|
77
|
+
const line = args.join(" ");
|
|
78
|
+
const slug = /\/(admin|gateway) /.exec(`${line} `)?.[1] as "admin" | "gateway" | undefined;
|
|
79
|
+
if (line.startsWith("artifacts docker tags list") && slug) {
|
|
80
|
+
const tags = opts.tags?.[slug] ?? {};
|
|
81
|
+
return ok(
|
|
82
|
+
Object.entries(tags)
|
|
83
|
+
.map(
|
|
84
|
+
([v, d]) => `projects/p/locations/l/repositories/r/packages/${slug}/tags/${v}\t${d}`,
|
|
85
|
+
)
|
|
86
|
+
.join("\n"),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return ok("");
|
|
90
|
+
},
|
|
91
|
+
fetchManifest: async (version): Promise<ReleaseManifestResult> => {
|
|
92
|
+
fetched.push(version);
|
|
93
|
+
const m = manifests[version];
|
|
94
|
+
return m
|
|
95
|
+
? { ok: true, manifest: m }
|
|
96
|
+
: {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: { code: "registry", message: `no release manifest for v${version}` },
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
imageForms: createReleaseImageForms(fakeRegistryFetch(opts.calls ?? [], opts.registry)),
|
|
102
|
+
...(opts.hints ? { hintDigests: async () => opts.hints as ServingDigests } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("recordMatchesServing", () => {
|
|
107
|
+
it("regression #1025: the index alone does not match what Cloud Run reports", () => {
|
|
108
|
+
expect(recordMatchesServing(META, LIVE_SERVING)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("matches by the recorded platform forms", () => {
|
|
112
|
+
const both = { ...META, platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 } };
|
|
113
|
+
expect(recordMatchesServing(both, LIVE_SERVING)).toBe(true);
|
|
114
|
+
// Still by the index when a revision names it.
|
|
115
|
+
expect(recordMatchesServing(META, { admin: [ADMIN_INDEX], gateway: [GATEWAY_INDEX] })).toBe(
|
|
116
|
+
true,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("both services must match, and no record never matches", () => {
|
|
121
|
+
const both = { ...META, platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 } };
|
|
122
|
+
expect(recordMatchesServing(both, { admin: [ADMIN_AMD64], gateway: [D("6")] })).toBe(false);
|
|
123
|
+
expect(recordMatchesServing({}, LIVE_SERVING)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("findServingRelease", () => {
|
|
128
|
+
it("regression #1025: identifies v0.10.64 from the platform digests inside its signed indexes", async () => {
|
|
129
|
+
const calls: string[] = [];
|
|
130
|
+
const d = deps({ calls });
|
|
131
|
+
const r = await findServingRelease(META, LIVE_SERVING, d);
|
|
132
|
+
expect(r).toEqual({
|
|
133
|
+
ok: true,
|
|
134
|
+
manifest: V064,
|
|
135
|
+
platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 },
|
|
136
|
+
});
|
|
137
|
+
expect(d.fetched).toEqual(["0.10.64"]);
|
|
138
|
+
expect(calls).toHaveLength(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("an attestation manifest of the index is not a serving image of the release", async () => {
|
|
142
|
+
const r = await findServingRelease(
|
|
143
|
+
META,
|
|
144
|
+
{ admin: [ADMIN_ATTESTATION], gateway: [GATEWAY_AMD64] },
|
|
145
|
+
deps(),
|
|
146
|
+
);
|
|
147
|
+
expect(r.ok).toBe(false);
|
|
148
|
+
expect(r.ok ? "" : r.reason).toContain(
|
|
149
|
+
"The gateway serves v0.10.64's image, the admin does not.",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses the signed platform digests without reading the index", async () => {
|
|
154
|
+
const calls: string[] = [];
|
|
155
|
+
const signed = {
|
|
156
|
+
...V064,
|
|
157
|
+
platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64, ops: D("3") },
|
|
158
|
+
};
|
|
159
|
+
const r = await findServingRelease(
|
|
160
|
+
META,
|
|
161
|
+
LIVE_SERVING,
|
|
162
|
+
deps({ calls, manifests: { "0.10.64": signed } }),
|
|
163
|
+
);
|
|
164
|
+
expect(r.ok).toBe(true);
|
|
165
|
+
expect(calls).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("a digest in no signed index is still refused with the current message", async () => {
|
|
169
|
+
const r = await findServingRelease(META, { admin: [D("7")], gateway: [D("6")] }, deps());
|
|
170
|
+
expect(r).toMatchObject({ ok: false, retryable: false });
|
|
171
|
+
expect(r.ok ? "" : r.reason).toContain(
|
|
172
|
+
"are not the ones install.json names, and they do not belong to one signed Caelo release",
|
|
173
|
+
);
|
|
174
|
+
expect(r.ok ? "" : r.reason).toContain(`admin ${D("7")}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("a registry outage reading the index is retryable, a tampered index is not", async () => {
|
|
178
|
+
const down = await findServingRelease(META, LIVE_SERVING, deps({ registry: { status: 503 } }));
|
|
179
|
+
expect(down).toMatchObject({ ok: false, retryable: true });
|
|
180
|
+
expect(down.ok ? "" : down.reason).toContain("could not be reached");
|
|
181
|
+
|
|
182
|
+
const tampered = await findServingRelease(
|
|
183
|
+
META,
|
|
184
|
+
LIVE_SERVING,
|
|
185
|
+
deps({ registry: { tampered: new TextEncoder().encode("{}") } }),
|
|
186
|
+
);
|
|
187
|
+
expect(tampered).toMatchObject({ ok: false, retryable: false });
|
|
188
|
+
expect(tampered.ok ? "" : tampered.reason).toContain("could not be verified");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("finds the release by the template's index when install.json names none", async () => {
|
|
192
|
+
const { releaseVersion: _none, ...unversioned } = META;
|
|
193
|
+
const meta = { ...unversioned, imageDigests: { admin: D("9"), gateway: D("8") } };
|
|
194
|
+
const d = deps({
|
|
195
|
+
tags: { admin: { "0.10.64": ADMIN_INDEX } },
|
|
196
|
+
hints: { admin: [ADMIN_INDEX], gateway: [GATEWAY_INDEX] },
|
|
197
|
+
});
|
|
198
|
+
const r = await findServingRelease(meta, LIVE_SERVING, d);
|
|
199
|
+
expect(r.ok && r.manifest.version).toBe("0.10.64");
|
|
200
|
+
expect(d.fetched).toEqual(["0.10.64"]);
|
|
201
|
+
});
|
|
202
|
+
});
|
package/src/upgrader-records.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* Images that belong to no signed release are a deployment nobody can
|
|
16
16
|
* vouch for, so the run stops and says so.
|
|
17
17
|
*
|
|
18
|
+
* A serving image is a release's image when its digest is one of that
|
|
19
|
+
* image's forms (#1025, upgrade/image-forms.ts): the index the manifest
|
|
20
|
+
* signs, or a platform manifest inside it. Cloud Run records the
|
|
21
|
+
* linux/amd64 platform manifest it resolved, so comparing with the index
|
|
22
|
+
* alone never matched on a live install.
|
|
23
|
+
*
|
|
18
24
|
* Which releases to try comes from untrusted hints — the version
|
|
19
25
|
* install.json names, and the release tags pointing at the serving digests
|
|
20
26
|
* in the fixed mirror (releaseVersionForDigest). A wrong hint costs one
|
|
@@ -25,6 +31,7 @@ import type { GcloudRunner } from "./gcloud-retry.js";
|
|
|
25
31
|
import type { ImageDigests, InstallMetadata } from "./install-state.js";
|
|
26
32
|
import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
|
|
27
33
|
import type { RollSlug } from "./upgrade/app-roll.js";
|
|
34
|
+
import type { ReleaseImageForms } from "./upgrade/image-forms.js";
|
|
28
35
|
import { releaseVersionForDigest } from "./upgrade/release-images.js";
|
|
29
36
|
|
|
30
37
|
/** Every digest each serving image is known by (gcp-lookup.ts servingImageDigests). */
|
|
@@ -32,18 +39,35 @@ export type ServingDigests = Readonly<Record<RollSlug, readonly string[]>>;
|
|
|
32
39
|
|
|
33
40
|
const SLUGS: readonly RollSlug[] = ["admin", "gateway"];
|
|
34
41
|
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Whether install.json names the images that serve: per service, the
|
|
44
|
+
* recorded index digest or its recorded linux/amd64 form (#1025) is one of
|
|
45
|
+
* the digests the serving revision is known by.
|
|
46
|
+
*/
|
|
36
47
|
export function recordMatchesServing(
|
|
37
|
-
recorded:
|
|
48
|
+
recorded: Pick<InstallMetadata, "imageDigests" | "platformDigests">,
|
|
38
49
|
serving: ServingDigests,
|
|
39
50
|
): boolean {
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
const { imageDigests, platformDigests } = recorded;
|
|
52
|
+
if (!imageDigests) return false;
|
|
53
|
+
return SLUGS.every(
|
|
54
|
+
(slug) =>
|
|
55
|
+
serving[slug].includes(imageDigests[slug]) ||
|
|
56
|
+
(platformDigests !== undefined && serving[slug].includes(platformDigests[slug])),
|
|
57
|
+
);
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
/** What {@link findServingRelease} learned. */
|
|
45
61
|
export type ServingRelease =
|
|
46
|
-
| {
|
|
62
|
+
| {
|
|
63
|
+
readonly ok: true;
|
|
64
|
+
readonly manifest: ReleaseManifest;
|
|
65
|
+
/**
|
|
66
|
+
* The linux/amd64 form of each of its images (#1025), to record next
|
|
67
|
+
* to the index digests; null when it is not known for both.
|
|
68
|
+
*/
|
|
69
|
+
readonly platformDigests: ImageDigests | null;
|
|
70
|
+
}
|
|
47
71
|
| {
|
|
48
72
|
readonly ok: false;
|
|
49
73
|
/**
|
|
@@ -62,6 +86,15 @@ export interface ServingReleaseDeps {
|
|
|
62
86
|
readonly run: GcloudRunner;
|
|
63
87
|
/** fetchVerifiedReleaseManifest against the fixed registry. */
|
|
64
88
|
readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
|
|
89
|
+
/** Every digest a release image is known by (#1025; createReleaseImageForms). */
|
|
90
|
+
readonly imageForms: ReleaseImageForms;
|
|
91
|
+
/**
|
|
92
|
+
* The image digests the services' templates name (the index a deploy
|
|
93
|
+
* pinned; after a rollback, possibly another release's). Untrusted hints
|
|
94
|
+
* for which release tags to look up, asked only when nothing else found
|
|
95
|
+
* the serving release.
|
|
96
|
+
*/
|
|
97
|
+
readonly hintDigests?: () => Promise<ServingDigests>;
|
|
65
98
|
}
|
|
66
99
|
|
|
67
100
|
/**
|
|
@@ -79,7 +112,7 @@ export async function findServingRelease(
|
|
|
79
112
|
const unverified: string[] = [];
|
|
80
113
|
const unreachable: string[] = [];
|
|
81
114
|
const tried: string[] = [];
|
|
82
|
-
for await (const version of candidateVersions(meta, serving, deps
|
|
115
|
+
for await (const version of candidateVersions(meta, serving, deps)) {
|
|
83
116
|
if (tried.includes(version)) continue;
|
|
84
117
|
tried.push(version);
|
|
85
118
|
const fetched = await deps.fetchManifest(version);
|
|
@@ -92,12 +125,21 @@ export async function findServingRelease(
|
|
|
92
125
|
continue;
|
|
93
126
|
}
|
|
94
127
|
const m = fetched.manifest;
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
128
|
+
const judged = await judgeRelease(m, serving, deps.imageForms);
|
|
129
|
+
if (judged.kind === "unreadable") {
|
|
130
|
+
// The index could not be read, so this release is neither confirmed
|
|
131
|
+
// nor ruled out: an outage is retried, anything else is a release
|
|
132
|
+
// record that could not be verified.
|
|
133
|
+
(judged.transient ? unreachable : unverified).push(`v${version} (${judged.message})`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (judged.matching.length === SLUGS.length) {
|
|
137
|
+
return { ok: true, manifest: m, platformDigests: judged.platformDigests };
|
|
138
|
+
}
|
|
139
|
+
if (judged.matching.length > 0) {
|
|
140
|
+
const other = SLUGS.filter((s) => !judged.matching.includes(s));
|
|
99
141
|
partial.push(
|
|
100
|
-
`the ${matching.join(", ")} serves v${m.version}'s image, the ${other.join(", ")} does not`,
|
|
142
|
+
`the ${judged.matching.join(", ")} serves v${m.version}'s image, the ${other.join(", ")} does not`,
|
|
101
143
|
);
|
|
102
144
|
}
|
|
103
145
|
}
|
|
@@ -125,6 +167,51 @@ export async function findServingRelease(
|
|
|
125
167
|
};
|
|
126
168
|
}
|
|
127
169
|
|
|
170
|
+
/** Which of release `m`'s images serve, by {@link judgeRelease}. */
|
|
171
|
+
type ReleaseJudgement =
|
|
172
|
+
| {
|
|
173
|
+
readonly kind: "judged";
|
|
174
|
+
readonly matching: readonly RollSlug[];
|
|
175
|
+
readonly platformDigests: ImageDigests | null;
|
|
176
|
+
}
|
|
177
|
+
| { readonly kind: "unreadable"; readonly transient: boolean; readonly message: string };
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Which of `m`'s images serve. A serving digest equal to the signed index
|
|
181
|
+
* matches without a lookup; any other is compared with the image's
|
|
182
|
+
* platform forms (signed, or read from the content-checked index).
|
|
183
|
+
*/
|
|
184
|
+
async function judgeRelease(
|
|
185
|
+
m: ReleaseManifest,
|
|
186
|
+
serving: ServingDigests,
|
|
187
|
+
imageForms: ReleaseImageForms,
|
|
188
|
+
): Promise<ReleaseJudgement> {
|
|
189
|
+
const matching: RollSlug[] = [];
|
|
190
|
+
const platform: Partial<Record<RollSlug, string>> = {};
|
|
191
|
+
for (const slug of SLUGS) {
|
|
192
|
+
if (serving[slug].includes(m.digests[slug])) {
|
|
193
|
+
matching.push(slug);
|
|
194
|
+
const signed = m.platformDigests?.[slug];
|
|
195
|
+
if (signed) platform[slug] = signed;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const forms = await imageForms.of(m, slug);
|
|
199
|
+
if (!forms.ok)
|
|
200
|
+
return { kind: "unreadable", transient: forms.transient, message: forms.message };
|
|
201
|
+
const match = serving[slug].find((d) => forms.digests.includes(d));
|
|
202
|
+
if (match) {
|
|
203
|
+
matching.push(slug);
|
|
204
|
+
if (forms.amd64) platform[slug] = forms.amd64;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const { admin, gateway } = platform;
|
|
208
|
+
return {
|
|
209
|
+
kind: "judged",
|
|
210
|
+
matching,
|
|
211
|
+
platformDigests: admin && gateway ? { admin, gateway } : null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
128
215
|
/**
|
|
129
216
|
* Release versions worth checking, most likely first. Lazy: the version
|
|
130
217
|
* install.json names usually is the serving one, and then no tag listing
|
|
@@ -133,17 +220,26 @@ export async function findServingRelease(
|
|
|
133
220
|
async function* candidateVersions(
|
|
134
221
|
meta: InstallMetadata,
|
|
135
222
|
serving: ServingDigests,
|
|
136
|
-
|
|
223
|
+
deps: ServingReleaseDeps,
|
|
137
224
|
): AsyncGenerator<string> {
|
|
138
225
|
if (meta.releaseVersion) yield meta.releaseVersion;
|
|
139
|
-
// Release tags point at the index digest a deploy pins
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
226
|
+
// Release tags point at the index digest a deploy pins. The platform
|
|
227
|
+
// digest Cloud Run records for a revision (#1025) has no tag, so the
|
|
228
|
+
// indexes the service templates name are asked too: only hints, the
|
|
229
|
+
// signed manifest still decides.
|
|
230
|
+
const asked: string[] = [];
|
|
231
|
+
const tagged = async function* (digests: ServingDigests): AsyncGenerator<string> {
|
|
232
|
+
for (const slug of SLUGS) {
|
|
233
|
+
for (const digest of digests[slug]) {
|
|
234
|
+
if (asked.includes(`${slug}@${digest}`)) continue;
|
|
235
|
+
asked.push(`${slug}@${digest}`);
|
|
236
|
+
const version = await releaseVersionForDigest(deps.run, slug, digest);
|
|
237
|
+
if (version) yield version;
|
|
238
|
+
}
|
|
145
239
|
}
|
|
146
|
-
}
|
|
240
|
+
};
|
|
241
|
+
yield* tagged(serving);
|
|
242
|
+
if (deps.hintDigests) yield* tagged(await deps.hintDigests());
|
|
147
243
|
}
|
|
148
244
|
|
|
149
245
|
function capitalize(s: string): string {
|
package/src/wizards/gcp.ts
CHANGED
|
@@ -120,6 +120,7 @@ import {
|
|
|
120
120
|
} from "../state-backend.js";
|
|
121
121
|
import { acquireStateLock, GcsStateStore } from "../state-store.js";
|
|
122
122
|
import { lookupFailure, resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
|
|
123
|
+
import { createReleaseImageForms, platformDigestsOf } from "../upgrade/image-forms.js";
|
|
123
124
|
import {
|
|
124
125
|
releaseVersionForDigest,
|
|
125
126
|
resolveOpsImageRef,
|
|
@@ -500,7 +501,22 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
|
|
|
500
501
|
// the admin can install the next update itself.
|
|
501
502
|
if (upgraderReady && convergeReady && remoteMcpReady) {
|
|
502
503
|
const version = releaseVersion;
|
|
503
|
-
|
|
504
|
+
// #1025 — the linux/amd64 forms Cloud Run reports, next to the indexes,
|
|
505
|
+
// so the upgrader recognises what serves without a registry lookup.
|
|
506
|
+
const platform = await platformDigestsOf(imageDigests, createReleaseImageForms());
|
|
507
|
+
if (!platform.ok) {
|
|
508
|
+
log.warn(
|
|
509
|
+
yellow(
|
|
510
|
+
`install.json records the release by its image indexes only; their linux/amd64 forms could not be read (${platform.message}). The first in-product update reads them from the release registry.`,
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
recordImageDigests(
|
|
515
|
+
installId,
|
|
516
|
+
imageDigests,
|
|
517
|
+
version,
|
|
518
|
+
platform.ok ? platform.platformDigests : null,
|
|
519
|
+
);
|
|
504
520
|
recordInfraContractVersion(installId, INFRA_CONTRACT_VERSION);
|
|
505
521
|
if (!version) {
|
|
506
522
|
log.warn(
|