@lanes-sh/link 0.6.5 → 0.6.6
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/package.json +1 -1
- package/src/cli/commands/target.ts +28 -0
- package/src/deployments/deploy.ts +20 -29
- package/src/deployments/driver.ts +11 -0
- package/src/deployments/gcp/bucket.ts +157 -0
- package/src/deployments/gcp/driver.ts +6 -1
- package/src/deployments/gcp/gcloud.ts +34 -0
- package/src/deployments/gcp/iam.ts +179 -0
- package/src/deployments/gcp/provision.ts +167 -165
- package/src/deployments/record.ts +62 -0
- package/src/deployments/steps.ts +35 -4
- package/src/profile/deployments.ts +3 -0
- package/src/profile/registry.ts +6 -5
- package/src/profile/schema.ts +14 -0
package/package.json
CHANGED
|
@@ -66,6 +66,16 @@ export interface TargetSummary {
|
|
|
66
66
|
/** Whether a deployment is declared. Unknown, and false, for a pointer. */
|
|
67
67
|
readonly deployed: boolean;
|
|
68
68
|
readonly deployment: DeploymentIdentity | null;
|
|
69
|
+
/**
|
|
70
|
+
* When this target was last deployed, and which CLI release rolled it.
|
|
71
|
+
*
|
|
72
|
+
* The one part of a pointer entry that is *not* somewhere else: `deploy`
|
|
73
|
+
* writes the record on both ends, so this machine can answer "what is running
|
|
74
|
+
* up there" without following the pointer or waking the endpoint. Null means
|
|
75
|
+
* nothing has deployed this target since the field existed.
|
|
76
|
+
*/
|
|
77
|
+
readonly lastDeploy: string | null;
|
|
78
|
+
readonly lastDeployVersion: string | null;
|
|
69
79
|
/** Only when asked for; absent is "not asked", null is "asked, no answer". */
|
|
70
80
|
readonly url?: string | null;
|
|
71
81
|
}
|
|
@@ -136,6 +146,13 @@ async function survey(
|
|
|
136
146
|
|
|
137
147
|
/** One entry, rendered without following it. */
|
|
138
148
|
function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): TargetSummary {
|
|
149
|
+
// Both shapes carry it: the deploy record is written to the target's own
|
|
150
|
+
// workspace *and* to the pointer here, which is what keeps it readable offline.
|
|
151
|
+
const record = {
|
|
152
|
+
lastDeploy: entry.last_deploy ?? null,
|
|
153
|
+
lastDeployVersion: entry.last_deploy_version ?? null,
|
|
154
|
+
};
|
|
155
|
+
|
|
139
156
|
if (isPointer(entry)) {
|
|
140
157
|
return {
|
|
141
158
|
name,
|
|
@@ -147,6 +164,7 @@ function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): T
|
|
|
147
164
|
knowledge: null,
|
|
148
165
|
deployed: false,
|
|
149
166
|
deployment: null,
|
|
167
|
+
...record,
|
|
150
168
|
};
|
|
151
169
|
}
|
|
152
170
|
|
|
@@ -160,6 +178,7 @@ function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): T
|
|
|
160
178
|
knowledge: null,
|
|
161
179
|
deployed: entry.deploy !== undefined,
|
|
162
180
|
deployment: deploymentIdentity(entry.deploy),
|
|
181
|
+
...record,
|
|
163
182
|
};
|
|
164
183
|
}
|
|
165
184
|
|
|
@@ -328,6 +347,15 @@ export async function targetShow(name: string | undefined, flags: TargetFlags):
|
|
|
328
347
|
[' region', summary.deployment.region],
|
|
329
348
|
...(summary.deployment.project ? [[' project', summary.deployment.project]] : []),
|
|
330
349
|
[' address', url ?? style.dim('not answering — is it deployed?')],
|
|
350
|
+
// The release that rolled it, which is the version of this CLI running up
|
|
351
|
+
// there: the image is built from the installed package. Recorded by
|
|
352
|
+
// `deploy` once the rollout succeeded, so a target deployed by an older
|
|
353
|
+
// CLI than this one says so rather than saying nothing.
|
|
354
|
+
[' last deploy', summary.lastDeploy ?? style.dim('not recorded')],
|
|
355
|
+
[
|
|
356
|
+
' version',
|
|
357
|
+
summary.lastDeployVersion ?? style.dim('not recorded — deploy again to record it'),
|
|
358
|
+
],
|
|
331
359
|
]);
|
|
332
360
|
});
|
|
333
361
|
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ConfigError,
|
|
3
|
-
recordTarget,
|
|
4
3
|
resolveTargetWorkspace,
|
|
5
4
|
resolveWorkspaceRoot,
|
|
6
5
|
type DeployConfig,
|
|
7
6
|
} from '#profile';
|
|
8
7
|
import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
|
|
9
8
|
import { staleNudge } from '#cli/release.ts';
|
|
9
|
+
import { version } from '#cli/version.ts';
|
|
10
10
|
import { confirm, isInteractive } from '#cli/prompt.ts';
|
|
11
11
|
import { openSecretStoreFor, resolveProfile, type GlobalFlags } from '#cli/runtime.ts';
|
|
12
12
|
import { resolveTarget, vaultEnv } from './bootstrap.ts';
|
|
13
|
+
import { recordDeployment, type DeploymentRecord } from './record.ts';
|
|
13
14
|
import { printSteps, runSteps } from './steps.ts';
|
|
14
15
|
import { driverFor } from './drivers.ts';
|
|
15
16
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
@@ -186,7 +187,10 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
186
187
|
printSteps(driver, [...provision, ...rollout]);
|
|
187
188
|
print('');
|
|
188
189
|
if (workspace) print(style.dim(` the workspace would be uploaded to ${workspace}`));
|
|
189
|
-
|
|
190
|
+
// Not "nothing was run": planning the list above reads the IAM policies this
|
|
191
|
+
// deploy would change, because what it supersedes is a fact about what is
|
|
192
|
+
// there. Reads only, and no credential among them.
|
|
193
|
+
print(style.dim(' --dry-run: nothing was changed, and no credential was read or written.'));
|
|
190
194
|
return;
|
|
191
195
|
}
|
|
192
196
|
|
|
@@ -261,6 +265,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
261
265
|
// uploaded is a profile that gets served, and repairing a narrower set than
|
|
262
266
|
// the upload sends would leave a served profile without the surface — this
|
|
263
267
|
// bug again, one profile over.
|
|
268
|
+
let recorded: DeploymentRecord | null = null;
|
|
264
269
|
if (workspace) {
|
|
265
270
|
// The pre-flight that used to sit here is gone with contract 1. It refused
|
|
266
271
|
// a deploy carrying a profile that did not declare the target, because the
|
|
@@ -296,40 +301,26 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
296
301
|
// listing and no writes.
|
|
297
302
|
await migrateWorkspace(workspace, { apply: true });
|
|
298
303
|
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
// up will read to learn where its credentials and bytes are — and a
|
|
304
|
-
// revision that boots before it lands has nothing to open.
|
|
305
|
-
//
|
|
306
|
-
// The local entry then becomes a *pointer*. That is the whole of ADR-052 in
|
|
307
|
-
// two lines: after this, exactly one file declares this target, and this
|
|
308
|
-
// machine holds a reference to it rather than a copy. ADR-044's index
|
|
309
|
-
// existed because the profile's own block was the only record and could be
|
|
310
|
-
// lost in one edit; there is no second copy left to lose.
|
|
311
|
-
const stamped = new Date().toISOString();
|
|
312
|
-
|
|
313
|
-
await recordTarget(workspace, target, {
|
|
314
|
-
...declared,
|
|
315
|
-
primary: resolution.profile,
|
|
316
|
-
last_deploy: stamped,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// **This machine's registry, not the target's.** `resolution.workspaceRoot`
|
|
320
|
-
// is the workspace the profile was *found* in, which for a deployed target is
|
|
321
|
-
// the bucket — so writing the pointer there pointed the bucket at itself, and
|
|
322
|
-
// the revision that came up refused to open its own target.
|
|
323
|
-
await recordTarget(resolveWorkspaceRoot(), target, {
|
|
304
|
+
// Where this deployment lives, in both registries — see `record.ts`. The
|
|
305
|
+
// declaration has to land before the revision boots, so it goes here rather
|
|
306
|
+
// than after the rollout; what rolled it is written once it has.
|
|
307
|
+
recorded = {
|
|
324
308
|
workspace,
|
|
309
|
+
target,
|
|
310
|
+
declared,
|
|
325
311
|
primary: resolution.profile,
|
|
326
|
-
|
|
327
|
-
}
|
|
312
|
+
at: new Date().toISOString(),
|
|
313
|
+
};
|
|
314
|
+
await recordDeployment(recorded);
|
|
328
315
|
}
|
|
329
316
|
|
|
330
317
|
heading('Rolling out');
|
|
331
318
|
await runSteps(driver, rollout);
|
|
332
319
|
|
|
320
|
+
// The release that rolled it, now that it has. `version()` is read from the
|
|
321
|
+
// installed package, which is the same tree the image was built from.
|
|
322
|
+
if (recorded) await recordDeployment({ ...recorded, version: version() });
|
|
323
|
+
|
|
333
324
|
const url = await driver.url(deployConfig);
|
|
334
325
|
if (!url) {
|
|
335
326
|
print(
|
|
@@ -33,6 +33,17 @@ export interface DeployStep {
|
|
|
33
33
|
readonly argv: readonly string[];
|
|
34
34
|
/** A step whose failure is expected when the thing already exists. */
|
|
35
35
|
readonly tolerateFailure?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* A step that takes something away rather than creating it.
|
|
38
|
+
*
|
|
39
|
+
* Its "already done" is the opposite message: a create that finds the thing
|
|
40
|
+
* present and a removal that finds it absent are both the expected case on
|
|
41
|
+
* every deploy after the one that did the work. Told apart explicitly rather
|
|
42
|
+
* than guessed from the argv, because reading `NOT_FOUND` as success on a step
|
|
43
|
+
* that was *adding* something would hide the exact failure this repository has
|
|
44
|
+
* already shipped twice.
|
|
45
|
+
*/
|
|
46
|
+
readonly removes?: boolean;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
export interface PlanInput {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { layout } from '#profile';
|
|
2
|
+
import type { DeployStep } from '../driver.ts';
|
|
3
|
+
import {
|
|
4
|
+
removalStep,
|
|
5
|
+
supersededBindings,
|
|
6
|
+
type ConditionedGrant,
|
|
7
|
+
type PolicyReader,
|
|
8
|
+
} from './iam.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The bucket a deployed target keeps everything in, and who may touch what in it.
|
|
12
|
+
*
|
|
13
|
+
* Split out of `provision.ts` because it is a different subject from the rest of
|
|
14
|
+
* that file. Everything else there is "create the thing this deploy needs";
|
|
15
|
+
* this is one resource's access policy, and it is the only place in the
|
|
16
|
+
* repository where ADR-007 is enforced by something other than the code being
|
|
17
|
+
* unable to write.
|
|
18
|
+
*
|
|
19
|
+
* Two conditioned bindings rather than one blanket `objectAdmin`, because the
|
|
20
|
+
* bucket now holds the config as well as the data. ADR-007 says a deployed
|
|
21
|
+
* instance never mutates its own configuration. That used to be enforced by the
|
|
22
|
+
* image being read-only, which stopped being true when the workspace moved into
|
|
23
|
+
* the bucket (ADR-023). This is where the guarantee went: the revision may write
|
|
24
|
+
* what it owns and may only read what declares what it is.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The two grants, as data, so the same list drives what is added and what is
|
|
29
|
+
* recognised as an earlier version of itself.
|
|
30
|
+
*
|
|
31
|
+
* A provider manifest is configuration that happens to live inside the profile's
|
|
32
|
+
* directory (ADR-030), so `data/` alone no longer separates what the revision
|
|
33
|
+
* owns from what declares what it is.
|
|
34
|
+
*
|
|
35
|
+
* **One `startsWith` per profile, because Cloud Storage IAM conditions cannot
|
|
36
|
+
* express anything else.** Their CEL is a restricted subset — `resource.type`,
|
|
37
|
+
* `resource.name` with `startsWith`/`endsWith`/`==`, and the date functions —
|
|
38
|
+
* and it has no `matches`. This was a regex, and it was refused twice over:
|
|
39
|
+
* first because it spelled the dot `\.`, which is not a CEL escape, so the
|
|
40
|
+
* string literal would not parse; then, with that fixed, because `matches` is
|
|
41
|
+
* `undeclared` in this dialect.
|
|
42
|
+
*
|
|
43
|
+
* Enumerating the served profiles is expressible in the subset that does exist,
|
|
44
|
+
* and it makes `grants.test.ts` honest as a side effect: that file evaluates
|
|
45
|
+
* these as JavaScript, where `startsWith` means what it means here and `matches`
|
|
46
|
+
* quietly did not.
|
|
47
|
+
*/
|
|
48
|
+
export function bucketGrants(bucket: string, profiles: readonly string[]): ConditionedGrant[] {
|
|
49
|
+
const objectsUnder = (path: string): string =>
|
|
50
|
+
`resource.name.startsWith("projects/_/buckets/${bucket}/objects/${path}")`;
|
|
51
|
+
const objectIs = (path: string): string =>
|
|
52
|
+
`resource.name == "projects/_/buckets/${bucket}/objects/${path}"`;
|
|
53
|
+
|
|
54
|
+
const manifestPrefixes = profiles.map((profile) =>
|
|
55
|
+
objectsUnder(`${layout.providers(profile)}/`),
|
|
56
|
+
);
|
|
57
|
+
// No profiles leaves the carve-out off rather than guessing at one: the
|
|
58
|
+
// revision keeps write on its own data, as it did before this existed.
|
|
59
|
+
const manifests = manifestPrefixes.length > 0 ? `(${manifestPrefixes.join(' || ')})` : null;
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
// objectAdmin, not objectViewer: state, the log, attachments, memory and
|
|
64
|
+
// skills are all written by the running endpoint.
|
|
65
|
+
role: 'roles/storage.objectAdmin',
|
|
66
|
+
title: 'owns-its-data',
|
|
67
|
+
expression: `${objectsUnder('data/')}${manifests ? ` && !${manifests}` : ''}`,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
// `expression=true` was here, which is every object in the bucket — the
|
|
71
|
+
// step title and ADR-023 both claim a narrowing this did not do. The
|
|
72
|
+
// config the revision reads is the workspace file, the profiles beside it,
|
|
73
|
+
// and each profile's own manifests, so name exactly those.
|
|
74
|
+
role: 'roles/storage.objectViewer',
|
|
75
|
+
title: 'reads-its-config',
|
|
76
|
+
expression: `${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')}${manifests ? ` || ${manifests}` : ''}`,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const TITLES: Record<string, string> = {
|
|
82
|
+
'owns-its-data': 'let the revision write its own data, but not the manifests in it',
|
|
83
|
+
'reads-its-config': 'let the revision read its config, and only read it',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Everything a deploy does to the bucket: create it, grant, and un-grant. */
|
|
87
|
+
export async function bucketSteps(input: {
|
|
88
|
+
readonly bucket: string;
|
|
89
|
+
readonly project: string;
|
|
90
|
+
readonly region: string;
|
|
91
|
+
readonly serviceAccount: string | undefined;
|
|
92
|
+
readonly profiles: readonly string[];
|
|
93
|
+
readonly policy?: PolicyReader | undefined;
|
|
94
|
+
}): Promise<DeployStep[]> {
|
|
95
|
+
const steps: DeployStep[] = [
|
|
96
|
+
{
|
|
97
|
+
title: `create the bucket gs://${input.bucket}`,
|
|
98
|
+
argv: [
|
|
99
|
+
'storage',
|
|
100
|
+
'buckets',
|
|
101
|
+
'create',
|
|
102
|
+
`gs://${input.bucket}`,
|
|
103
|
+
'--project',
|
|
104
|
+
input.project,
|
|
105
|
+
'--location',
|
|
106
|
+
input.region,
|
|
107
|
+
// Blobs here are read and written by one instance at a time and never
|
|
108
|
+
// served publicly; uniform access removes per-object ACLs as a way to
|
|
109
|
+
// get that wrong.
|
|
110
|
+
'--uniform-bucket-level-access',
|
|
111
|
+
],
|
|
112
|
+
tolerateFailure: true,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
if (!input.serviceAccount) return steps;
|
|
117
|
+
|
|
118
|
+
const member = `serviceAccount:${input.serviceAccount}`;
|
|
119
|
+
const grants = bucketGrants(input.bucket, input.profiles);
|
|
120
|
+
|
|
121
|
+
for (const grant of grants) {
|
|
122
|
+
steps.push({
|
|
123
|
+
title: TITLES[grant.title] ?? `bind ${grant.role}`,
|
|
124
|
+
argv: [
|
|
125
|
+
'storage',
|
|
126
|
+
'buckets',
|
|
127
|
+
'add-iam-policy-binding',
|
|
128
|
+
`gs://${input.bucket}`,
|
|
129
|
+
'--member',
|
|
130
|
+
member,
|
|
131
|
+
'--role',
|
|
132
|
+
grant.role,
|
|
133
|
+
'--condition',
|
|
134
|
+
`title=${grant.title},expression=${grant.expression}`,
|
|
135
|
+
],
|
|
136
|
+
tolerateFailure: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// After the additions above, and only ever after them — see `removalStep`.
|
|
141
|
+
const current = (await input.policy?.bucket(input.bucket)) ?? null;
|
|
142
|
+
if (current === null) return steps;
|
|
143
|
+
|
|
144
|
+
for (const binding of supersededBindings({ current, member, desired: grants })) {
|
|
145
|
+
const step = removalStep({
|
|
146
|
+
resource: ['storage', 'buckets', 'remove-iam-policy-binding', `gs://${input.bucket}`],
|
|
147
|
+
member,
|
|
148
|
+
binding,
|
|
149
|
+
title: binding.condition
|
|
150
|
+
? `drop the superseded "${binding.condition.title}" binding an earlier deploy left`
|
|
151
|
+
: `drop the unconditioned ${binding.role} an earlier deploy left`,
|
|
152
|
+
});
|
|
153
|
+
if (step) steps.push(step);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return steps;
|
|
157
|
+
}
|
|
@@ -13,6 +13,7 @@ import { encodeRef } from '../adapters/gcp-secret-manager.ts';
|
|
|
13
13
|
import {
|
|
14
14
|
captureGcloud,
|
|
15
15
|
gcloudPath,
|
|
16
|
+
gcloudPolicy,
|
|
16
17
|
requireProject,
|
|
17
18
|
runGcloud,
|
|
18
19
|
serviceUrl,
|
|
@@ -176,7 +177,11 @@ export const cloudRunDriver: DeployDriver = {
|
|
|
176
177
|
},
|
|
177
178
|
|
|
178
179
|
provision(input: ProvisionInput): Promise<DeployStep[]> {
|
|
179
|
-
|
|
180
|
+
// The policy reader is what lets a deploy take away the bindings it
|
|
181
|
+
// supersedes rather than only add the ones it means. It reads; it changes
|
|
182
|
+
// nothing, and a `--dry-run` that skipped it would print a plan missing
|
|
183
|
+
// exactly the steps this deploy exists to make visible.
|
|
184
|
+
return provisionSteps(input, gcloudPolicy);
|
|
180
185
|
},
|
|
181
186
|
|
|
182
187
|
plan: deployPlan,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ConfigError, type DeployConfig } from '#profile';
|
|
2
2
|
import type { CommandResult } from '../driver.ts';
|
|
3
|
+
import type { PolicyBinding, PolicyReader } from './iam.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Shelling out to `gcloud`.
|
|
@@ -176,3 +177,36 @@ export async function openBillingAccounts(): Promise<{ id: string; name: string
|
|
|
176
177
|
})
|
|
177
178
|
.filter((account) => account.id.length > 0);
|
|
178
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The IAM policies a deploy is about to edit, read before it edits them.
|
|
183
|
+
*
|
|
184
|
+
* Read rather than remembered. `add-iam-policy-binding` adds a binding beside an
|
|
185
|
+
* existing one whenever the condition differs, so a deploy that changes a
|
|
186
|
+
* condition leaves the old one granting whatever it granted — and nothing in
|
|
187
|
+
* this repository knows what the last deploy wrote (`docs/detailed/init.md`
|
|
188
|
+
* rules out the state file that would). IAM is what actually decides, so IAM is
|
|
189
|
+
* what gets asked.
|
|
190
|
+
*
|
|
191
|
+
* Every failure answers `null`, which plans no removals at all: a missing
|
|
192
|
+
* `gcloud`, a bucket that does not exist yet on a first deploy, and a policy
|
|
193
|
+
* this login may not read are all "could not look", and none of them is a reason
|
|
194
|
+
* to guess at what to take away.
|
|
195
|
+
*/
|
|
196
|
+
async function readPolicy(argv: readonly string[]): Promise<PolicyBinding[] | null> {
|
|
197
|
+
const result = await captureGcloud(argv);
|
|
198
|
+
if (!result.ok) return null;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
return (JSON.parse(result.stdout) as { bindings?: PolicyBinding[] }).bindings ?? [];
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const gcloudPolicy: PolicyReader = {
|
|
208
|
+
bucket: (bucket) =>
|
|
209
|
+
readPolicy(['storage', 'buckets', 'get-iam-policy', `gs://${bucket}`, '--format', 'json']),
|
|
210
|
+
project: (project) =>
|
|
211
|
+
readPolicy(['projects', 'get-iam-policy', project, '--format', 'json']),
|
|
212
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { DeployStep } from '../driver.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bindings a deploy did not write, and what to do about the ones it replaced.
|
|
5
|
+
*
|
|
6
|
+
* **`add-iam-policy-binding` adds.** It is keyed on the whole binding —
|
|
7
|
+
* role, member *and* condition — so changing a condition's expression does not
|
|
8
|
+
* edit the binding, it writes a second one beside the first. IAM then evaluates
|
|
9
|
+
* the set as a permissive union: whichever expression is widest wins, and the
|
|
10
|
+
* narrowing the new one describes never happens.
|
|
11
|
+
*
|
|
12
|
+
* That is not hypothetical. Every step in `provision.ts` tolerates failure, so
|
|
13
|
+
* two rejected attempts at a condition left `reads-its-config` sitting at
|
|
14
|
+
* `expression=true` — `objectViewer` on every object in the bucket, under a
|
|
15
|
+
* title claiming the opposite, and the exact state ADR-007 exists to prevent.
|
|
16
|
+
* Three deploys since have each added a correct binding beside it and changed
|
|
17
|
+
* nothing about what the revision could read.
|
|
18
|
+
*
|
|
19
|
+
* So a deploy has to *remove* what it supersedes, and the removal has to name
|
|
20
|
+
* the old binding exactly. Nothing here keeps a record of what the last deploy
|
|
21
|
+
* wrote — `docs/detailed/init.md` rules out state files, leases and drift
|
|
22
|
+
* reconciliation, and a record would only ever agree with itself anyway. The
|
|
23
|
+
* policy is read instead: IAM is the thing that actually decides, so IAM is what
|
|
24
|
+
* gets asked, the same argument `unboundRotatableRefs` makes for `doctor`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** One binding, as `get-iam-policy --format=json` returns it. */
|
|
28
|
+
export interface PolicyBinding {
|
|
29
|
+
readonly role?: string | undefined;
|
|
30
|
+
readonly members?: readonly string[] | undefined;
|
|
31
|
+
readonly condition?:
|
|
32
|
+
| {
|
|
33
|
+
readonly title?: string | undefined;
|
|
34
|
+
readonly expression?: string | undefined;
|
|
35
|
+
readonly description?: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
| undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A binding this deploy writes: a role, and the condition that scopes it. */
|
|
41
|
+
export interface ConditionedGrant {
|
|
42
|
+
readonly role: string;
|
|
43
|
+
readonly title: string;
|
|
44
|
+
readonly expression: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reading a policy this deploy is about to change.
|
|
49
|
+
*
|
|
50
|
+
* An interface rather than a direct call so `provisionSteps` stays what it is —
|
|
51
|
+
* a pure function from a target to a list of commands — and so every test of it
|
|
52
|
+
* runs with no cloud project near it. Absent means "nothing was asked", which
|
|
53
|
+
* plans no removals at all: a deploy that cannot see the current policy must not
|
|
54
|
+
* guess at what to take away.
|
|
55
|
+
*/
|
|
56
|
+
export interface PolicyReader {
|
|
57
|
+
/** The bucket's own policy, or null when it could not be read. */
|
|
58
|
+
bucket(bucket: string): Promise<readonly PolicyBinding[] | null>;
|
|
59
|
+
/** The project's policy, on the same contract. */
|
|
60
|
+
project(project: string): Promise<readonly PolicyBinding[] | null>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether an existing binding is exactly one of the grants being applied. */
|
|
64
|
+
function isDesired(binding: PolicyBinding, desired: readonly ConditionedGrant[]): boolean {
|
|
65
|
+
return desired.some(
|
|
66
|
+
(grant) =>
|
|
67
|
+
grant.role === binding.role &&
|
|
68
|
+
grant.title === binding.condition?.title &&
|
|
69
|
+
grant.expression === binding.condition?.expression,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The bindings on this resource that an earlier version of this deploy wrote and
|
|
75
|
+
* this one no longer means.
|
|
76
|
+
*
|
|
77
|
+
* Two shapes qualify, and both are things only a deploy puts there:
|
|
78
|
+
*
|
|
79
|
+
* - **A condition with one of our titles and a different expression.** The
|
|
80
|
+
* `owns-its-data` binding that still names `skills/`, the `reads-its-config`
|
|
81
|
+
* one still saying `true`. Matched on the title rather than on the role, so
|
|
82
|
+
* that changing which role carries a title cleans the old one up too.
|
|
83
|
+
* - **An unconditioned binding on a role we only ever grant conditionally.**
|
|
84
|
+
* A grant with no condition is the whole bucket, which is the thing the
|
|
85
|
+
* condition exists to prevent; there is no version of this deploy for which
|
|
86
|
+
* it is the right answer.
|
|
87
|
+
*
|
|
88
|
+
* Anything else on the policy is left alone — other members, other roles, and
|
|
89
|
+
* every binding a human added deliberately.
|
|
90
|
+
*/
|
|
91
|
+
export function supersededBindings(input: {
|
|
92
|
+
readonly current: readonly PolicyBinding[];
|
|
93
|
+
readonly member: string;
|
|
94
|
+
readonly desired: readonly ConditionedGrant[];
|
|
95
|
+
}): PolicyBinding[] {
|
|
96
|
+
const titles = new Set(input.desired.map((grant) => grant.title));
|
|
97
|
+
const roles = new Set(input.desired.map((grant) => grant.role));
|
|
98
|
+
|
|
99
|
+
return input.current.filter((binding) => {
|
|
100
|
+
if (!(binding.members ?? []).includes(input.member)) return false;
|
|
101
|
+
if (isDesired(binding, input.desired)) return false;
|
|
102
|
+
|
|
103
|
+
const title = binding.condition?.title;
|
|
104
|
+
if (title !== undefined) return titles.has(title);
|
|
105
|
+
return binding.role !== undefined && roles.has(binding.role);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A condition as `--condition` wants it, delimiter and all.
|
|
111
|
+
*
|
|
112
|
+
* gcloud parses this value as comma-separated `key=value` pairs, so an
|
|
113
|
+
* expression containing a comma silently becomes two keys — and the removal has
|
|
114
|
+
* to match the stored condition *exactly*, including its description, or it
|
|
115
|
+
* removes nothing and reports that it removed nothing. The `^X^` prefix picks a
|
|
116
|
+
* different delimiter, which is gcloud's own escape for this (`gcloud topic
|
|
117
|
+
* escaping`).
|
|
118
|
+
*
|
|
119
|
+
* None of the expressions this repository writes contains a comma. The one being
|
|
120
|
+
* removed came off the policy, though, and so was written by some other version
|
|
121
|
+
* of this file.
|
|
122
|
+
*/
|
|
123
|
+
export function conditionFlag(condition: NonNullable<PolicyBinding['condition']>): string {
|
|
124
|
+
const parts: [string, string][] = [
|
|
125
|
+
['title', condition.title ?? ''],
|
|
126
|
+
['expression', condition.expression ?? ''],
|
|
127
|
+
...(condition.description ? ([['description', condition.description]] as [string, string][]) : []),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const text = parts.map(([key, value]) => `${key}=${value}`).join('');
|
|
131
|
+
const delimiter = [',', ';', ':', '#', '~', '%'].find((candidate) => !text.includes(candidate));
|
|
132
|
+
if (delimiter === undefined) {
|
|
133
|
+
// Six delimiters and every one of them is in the expression. Nothing this
|
|
134
|
+
// repository writes gets here; a binding that does is left in place rather
|
|
135
|
+
// than removed by an argv that would mean something else.
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const joined = parts.map(([key, value]) => `${key}=${value}`).join(delimiter);
|
|
140
|
+
return delimiter === ',' ? joined : `^${delimiter}^${joined}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The command that takes one superseded binding away.
|
|
145
|
+
*
|
|
146
|
+
* Always after the step that adds its replacement, never before. The two are one
|
|
147
|
+
* edit to a live policy, and doing them the other way round opens a window —
|
|
148
|
+
* seconds by the clock, longer once propagation is counted — in which the
|
|
149
|
+
* revision that is currently serving holds no grant at all.
|
|
150
|
+
*
|
|
151
|
+
* `null` for a binding whose condition cannot be spelled as an argument, which
|
|
152
|
+
* leaves it in place: an approximate `--condition` matches nothing, or worse,
|
|
153
|
+
* matches something else.
|
|
154
|
+
*/
|
|
155
|
+
export function removalStep(input: {
|
|
156
|
+
readonly resource: readonly string[];
|
|
157
|
+
readonly member: string;
|
|
158
|
+
readonly binding: PolicyBinding;
|
|
159
|
+
readonly title: string;
|
|
160
|
+
}): DeployStep | null {
|
|
161
|
+
const condition = input.binding.condition;
|
|
162
|
+
const flag = condition ? conditionFlag(condition) : 'None';
|
|
163
|
+
if (flag === '') return null;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
title: input.title,
|
|
167
|
+
argv: [
|
|
168
|
+
...input.resource,
|
|
169
|
+
'--member',
|
|
170
|
+
input.member,
|
|
171
|
+
'--role',
|
|
172
|
+
input.binding.role ?? '',
|
|
173
|
+
'--condition',
|
|
174
|
+
flag,
|
|
175
|
+
],
|
|
176
|
+
tolerateFailure: true,
|
|
177
|
+
removes: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { VAULT_DOCUMENT_REF, type SecretRef } from '#secrets';
|
|
2
2
|
import type { DeployStep, ProvisionInput } from '../driver.ts';
|
|
3
3
|
import { encodeRef } from '../adapters/gcp-secret-manager.ts';
|
|
4
|
-
import {
|
|
4
|
+
import { bucketSteps } from './bucket.ts';
|
|
5
5
|
import { requireProject } from './gcloud.ts';
|
|
6
|
+
import type { PolicyReader } from './iam.ts';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The project-level things a Cloud Run deploy needs to already exist.
|
|
@@ -56,6 +57,44 @@ export interface SecretGrant {
|
|
|
56
57
|
readonly refs: readonly SecretRef[];
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Bring each secret into existence, so the bindings below have something to
|
|
62
|
+
* attach to and the revision never needs `secrets.create` itself.
|
|
63
|
+
*
|
|
64
|
+
* That second half is the reason this is a step rather than left to whoever
|
|
65
|
+
* writes the value: `secretmanager.secrets.create` is a project-level
|
|
66
|
+
* permission, and a revision holding it could mint credential references of its
|
|
67
|
+
* own. So the container is created here and the revision only ever adds a
|
|
68
|
+
* version.
|
|
69
|
+
*
|
|
70
|
+
* The first half is why *read* grants need it too, which they did not have. A
|
|
71
|
+
* binding cannot attach to a secret that does not exist — `gcloud` answers
|
|
72
|
+
* `NOT_FOUND`, every step here tolerates failure, and the deploy carries on. On
|
|
73
|
+
* a first deploy that is exactly what happened to the endpoint's own bearer
|
|
74
|
+
* token: `prepareSecrets` mints it *after* provisioning, so the secret appeared
|
|
75
|
+
* a step too late to be bound and the revision could not read the one value it
|
|
76
|
+
* refuses to start without. A second deploy fixed it, which is why this survived
|
|
77
|
+
* — the failure only ever showed up once per project.
|
|
78
|
+
*/
|
|
79
|
+
export function createSteps(input: {
|
|
80
|
+
readonly project: string;
|
|
81
|
+
readonly refs: readonly SecretRef[];
|
|
82
|
+
}): DeployStep[] {
|
|
83
|
+
return input.refs.map((ref) => ({
|
|
84
|
+
title: `create the secret ${encodeRef(ref)}, so the revision never needs secrets.create`,
|
|
85
|
+
argv: [
|
|
86
|
+
'secrets',
|
|
87
|
+
'create',
|
|
88
|
+
encodeRef(ref),
|
|
89
|
+
'--project',
|
|
90
|
+
input.project,
|
|
91
|
+
'--replication-policy',
|
|
92
|
+
'automatic',
|
|
93
|
+
],
|
|
94
|
+
tolerateFailure: true,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
/** Read, named one secret at a time, so the grant needs no condition to be scoped. */
|
|
60
99
|
export function readSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
|
|
61
100
|
return refs.map((ref) => ({
|
|
@@ -80,39 +119,32 @@ export function readSteps({ project, serviceAccount, refs }: SecretGrant): Deplo
|
|
|
80
119
|
}
|
|
81
120
|
|
|
82
121
|
/**
|
|
83
|
-
* Write,
|
|
84
|
-
*
|
|
85
|
-
* never `secrets.create`, which is a project-level permission that would let it
|
|
86
|
-
* mint credential references of its own.
|
|
122
|
+
* Write, named one secret at a time, and never `secrets.create` — the container
|
|
123
|
+
* is made by `createSteps` so this grant can stay at "add a version".
|
|
87
124
|
*/
|
|
88
125
|
export function rotateSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
|
|
89
|
-
return refs.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
],
|
|
112
|
-
tolerateFailure: true,
|
|
113
|
-
},
|
|
114
|
-
];
|
|
115
|
-
});
|
|
126
|
+
return refs.map((ref) => ({
|
|
127
|
+
title: `let the revision rewrite ${ref}, and nothing else in the store`,
|
|
128
|
+
argv: [
|
|
129
|
+
'secrets',
|
|
130
|
+
'add-iam-policy-binding',
|
|
131
|
+
encodeRef(ref),
|
|
132
|
+
'--project',
|
|
133
|
+
project,
|
|
134
|
+
'--member',
|
|
135
|
+
`serviceAccount:${serviceAccount}`,
|
|
136
|
+
'--role',
|
|
137
|
+
'roles/secretmanager.secretVersionAdder',
|
|
138
|
+
'--condition',
|
|
139
|
+
'None',
|
|
140
|
+
],
|
|
141
|
+
tolerateFailure: true,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The union of two ref lists, deduplicated and ordered, so nothing is created twice. */
|
|
146
|
+
function union(...lists: readonly (readonly SecretRef[])[]): SecretRef[] {
|
|
147
|
+
return [...new Set(lists.flat())].sort();
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
/**
|
|
@@ -142,12 +174,74 @@ export function secretGrantSteps(
|
|
|
142
174
|
): DeployStep[] {
|
|
143
175
|
const { project, serviceAccount } = grant;
|
|
144
176
|
return [
|
|
177
|
+
...createSteps({ project, refs: union(grant.readable, grant.rotatable) }),
|
|
145
178
|
...readSteps({ project, serviceAccount, refs: grant.readable }),
|
|
146
179
|
...rotateSteps({ project, serviceAccount, refs: grant.rotatable }),
|
|
147
180
|
];
|
|
148
181
|
}
|
|
149
182
|
|
|
150
|
-
|
|
183
|
+
/**
|
|
184
|
+
* The project-wide secret read that per-secret bindings replaced.
|
|
185
|
+
*
|
|
186
|
+
* `roles/secretmanager.secretAccessor` on the whole project is what this deploy
|
|
187
|
+
* used to grant, and removing it from the code did not remove it from anybody's
|
|
188
|
+
* project: `add-iam-policy-binding` never took it away, and IAM unions what is
|
|
189
|
+
* there. It kept working, which is the problem — a connection made after a
|
|
190
|
+
* deploy could still be *read* by the revision through this binding while its
|
|
191
|
+
* per-secret grant was missing, so the connection authorised, answered, and
|
|
192
|
+
* reported `active` until the first token refresh needed a write. An hour of
|
|
193
|
+
* looking healthy, and then a 403 nowhere near its cause.
|
|
194
|
+
*
|
|
195
|
+
* Only ever removed when this run granted read per secret. With no `readable`
|
|
196
|
+
* set there is nothing to fall back to, and taking away the only grant the
|
|
197
|
+
* revision has would be an outage caused by tidying.
|
|
198
|
+
*/
|
|
199
|
+
async function projectReadRemoval(input: {
|
|
200
|
+
readonly project: string;
|
|
201
|
+
readonly member: string;
|
|
202
|
+
readonly readable: readonly SecretRef[];
|
|
203
|
+
readonly policy: PolicyReader | undefined;
|
|
204
|
+
}): Promise<DeployStep[]> {
|
|
205
|
+
if (input.readable.length === 0 || !input.policy) return [];
|
|
206
|
+
|
|
207
|
+
const current = await input.policy.project(input.project);
|
|
208
|
+
const present = (current ?? []).some(
|
|
209
|
+
(binding) =>
|
|
210
|
+
binding.role === 'roles/secretmanager.secretAccessor' &&
|
|
211
|
+
(binding.members ?? []).includes(input.member),
|
|
212
|
+
);
|
|
213
|
+
if (!present) return [];
|
|
214
|
+
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
title: 'drop the project-wide secret read the per-secret grants replaced',
|
|
218
|
+
argv: [
|
|
219
|
+
'projects',
|
|
220
|
+
'remove-iam-policy-binding',
|
|
221
|
+
input.project,
|
|
222
|
+
'--member',
|
|
223
|
+
input.member,
|
|
224
|
+
'--role',
|
|
225
|
+
'roles/secretmanager.secretAccessor',
|
|
226
|
+
// Irrespective of any condition: every shape of this binding is one an
|
|
227
|
+
// earlier version of this file wrote, and all of them are superseded.
|
|
228
|
+
'--all',
|
|
229
|
+
],
|
|
230
|
+
tolerateFailure: true,
|
|
231
|
+
removes: true,
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function provisionSteps(
|
|
237
|
+
input: ProvisionInput,
|
|
238
|
+
/**
|
|
239
|
+
* How to read the policies this deploy is about to change, so it can take away
|
|
240
|
+
* what it supersedes as well as add what it means. Absent plans no removals —
|
|
241
|
+
* see `PolicyReader`.
|
|
242
|
+
*/
|
|
243
|
+
policy?: PolicyReader,
|
|
244
|
+
): Promise<DeployStep[]> {
|
|
151
245
|
const cloudrun = requireProject(input.deploy, input.target);
|
|
152
246
|
const { project, region, service_account: serviceAccount } = cloudrun;
|
|
153
247
|
const steps: DeployStep[] = [];
|
|
@@ -206,6 +300,25 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
|
|
|
206
300
|
tolerateFailure: true,
|
|
207
301
|
});
|
|
208
302
|
|
|
303
|
+
// What a revision rewrites in its own credential store, named one secret at
|
|
304
|
+
// a time. Two kinds, and they arrive from different places:
|
|
305
|
+
//
|
|
306
|
+
// - the vault document, because `vault.put` is a capability an agent may
|
|
307
|
+
// hold under policy (ADR-022);
|
|
308
|
+
// - each connection's OAuth token, because a refresh persists and serving
|
|
309
|
+
// a request is what triggers it (ADR-026).
|
|
310
|
+
//
|
|
311
|
+
// The second was missing, and the shape of the miss is worth keeping in
|
|
312
|
+
// mind: nothing here was wrong, it was incomplete, and being incomplete
|
|
313
|
+
// looked exactly like being finished. Reading mail 403'd an hour after every
|
|
314
|
+
// deploy.
|
|
315
|
+
const rotatable = [
|
|
316
|
+
...(input.declared.vault?.adapter === 'secret'
|
|
317
|
+
? [input.declared.vault.ref ?? VAULT_DOCUMENT_REF]
|
|
318
|
+
: []),
|
|
319
|
+
...(input.rotatable ?? []),
|
|
320
|
+
];
|
|
321
|
+
|
|
209
322
|
// Read, named one secret at a time, for the same reason the write side is:
|
|
210
323
|
// a resource-level grant needs no condition to be scoped.
|
|
211
324
|
//
|
|
@@ -220,37 +333,19 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
|
|
|
220
333
|
// Affordable because the serving path reads by explicit ref: `list()` is a
|
|
221
334
|
// CLI call, and `secretAccessor` never carried `secrets.list` anyway.
|
|
222
335
|
// `readableRefs` derives the set from config and manifests at deploy time.
|
|
223
|
-
|
|
224
|
-
}
|
|
336
|
+
const readable = input.readable ?? [];
|
|
225
337
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
// Two steps each, and the first is what keeps the second narrow: the secret is
|
|
239
|
-
// created here so the revision only ever needs to *add a version*, never
|
|
240
|
-
// `secrets.create`, which is a project-level permission that would let it mint
|
|
241
|
-
// credential refs of its own. The binding is on the one secret, so it needs no
|
|
242
|
-
// condition to be scoped — a resource-level grant already is.
|
|
243
|
-
const writable = serviceAccount
|
|
244
|
-
? [
|
|
245
|
-
...(input.declared.vault?.adapter === 'secret'
|
|
246
|
-
? [input.declared.vault.ref ?? VAULT_DOCUMENT_REF]
|
|
247
|
-
: []),
|
|
248
|
-
...(input.rotatable ?? []),
|
|
249
|
-
]
|
|
250
|
-
: [];
|
|
251
|
-
|
|
252
|
-
if (serviceAccount) {
|
|
253
|
-
steps.push(...rotateSteps({ project, serviceAccount, refs: writable }));
|
|
338
|
+
steps.push(...createSteps({ project, refs: union(readable, rotatable) }));
|
|
339
|
+
steps.push(...readSteps({ project, serviceAccount, refs: readable }));
|
|
340
|
+
steps.push(...rotateSteps({ project, serviceAccount, refs: rotatable }));
|
|
341
|
+
steps.push(
|
|
342
|
+
...(await projectReadRemoval({
|
|
343
|
+
project,
|
|
344
|
+
member: `serviceAccount:${serviceAccount}`,
|
|
345
|
+
readable,
|
|
346
|
+
policy,
|
|
347
|
+
})),
|
|
348
|
+
);
|
|
254
349
|
}
|
|
255
350
|
|
|
256
351
|
// Any target that addresses a bucket, which deployed means all of them:
|
|
@@ -263,110 +358,17 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
|
|
|
263
358
|
input.declared.storage.adapter === 'gcs' || input.declared.storage.adapter === 's3';
|
|
264
359
|
const bucket = usesBucket ? input.declared.storage.bucket : undefined;
|
|
265
360
|
if (bucket) {
|
|
266
|
-
steps.push(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
'storage',
|
|
270
|
-
'buckets',
|
|
271
|
-
'create',
|
|
272
|
-
`gs://${bucket}`,
|
|
273
|
-
'--project',
|
|
361
|
+
steps.push(
|
|
362
|
+
...(await bucketSteps({
|
|
363
|
+
bucket,
|
|
274
364
|
project,
|
|
275
|
-
'--location',
|
|
276
365
|
region,
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
tolerateFailure: true,
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
if (serviceAccount) {
|
|
286
|
-
// Two conditioned bindings rather than one blanket objectAdmin, because
|
|
287
|
-
// the bucket now holds the config as well as the data.
|
|
288
|
-
//
|
|
289
|
-
// ADR-007 says a deployed instance never mutates its own configuration.
|
|
290
|
-
// That used to be enforced by the image being read-only, which stopped
|
|
291
|
-
// being true when the workspace moved into the bucket (ADR-023). This is
|
|
292
|
-
// where the guarantee went: the revision may write what it owns and may
|
|
293
|
-
// only read what declares what it is.
|
|
294
|
-
const objectsUnder = (path: string): string =>
|
|
295
|
-
`resource.name.startsWith("projects/_/buckets/${bucket}/objects/${path}")`;
|
|
296
|
-
const objectIs = (path: string): string =>
|
|
297
|
-
`resource.name == "projects/_/buckets/${bucket}/objects/${path}"`;
|
|
298
|
-
|
|
299
|
-
// A provider manifest is configuration that happens to live inside the
|
|
300
|
-
// profile's directory (ADR-030), so `data/` alone no longer separates what
|
|
301
|
-
// the revision owns from what declares what it is.
|
|
302
|
-
//
|
|
303
|
-
// **One `startsWith` per profile, because Cloud Storage IAM conditions
|
|
304
|
-
// cannot express anything else.** Their CEL is a restricted subset —
|
|
305
|
-
// `resource.type`, `resource.name` with `startsWith`/`endsWith`/`==`, and
|
|
306
|
-
// the date functions — and it has no `matches`. This was a regex, and it
|
|
307
|
-
// was refused twice over: first because it spelled the dot `\.`, which is
|
|
308
|
-
// not a CEL escape, so the string literal would not parse; then, with that
|
|
309
|
-
// fixed, because `matches` is `undeclared` in this dialect.
|
|
310
|
-
//
|
|
311
|
-
// Both bindings carry `tolerateFailure`, so each attempt printed a warning
|
|
312
|
-
// and left whatever conditions the bucket already had. On a real
|
|
313
|
-
// deployment that was `expression=true` on the read binding — every object
|
|
314
|
-
// in the bucket, the exact opposite of the narrowing the step title claims,
|
|
315
|
-
// and the state ADR-007 says must not exist.
|
|
316
|
-
//
|
|
317
|
-
// Enumerating the served profiles is expressible in the subset that does
|
|
318
|
-
// exist, and it makes `grants.test.ts` honest as a side effect: that file
|
|
319
|
-
// evaluates these as JavaScript, where `startsWith` means what it means
|
|
320
|
-
// here and `matches` quietly did not.
|
|
321
|
-
const manifestPrefixes = (input.profiles ?? []).map((profile) =>
|
|
322
|
-
objectsUnder(`${layout.providers(profile)}/`),
|
|
323
|
-
);
|
|
324
|
-
// No profiles leaves the carve-out off rather than guessing at one: the
|
|
325
|
-
// revision keeps write on its own data, as it did before this existed.
|
|
326
|
-
const providerManifests =
|
|
327
|
-
manifestPrefixes.length > 0 ? `(${manifestPrefixes.join(' || ')})` : null;
|
|
328
|
-
|
|
329
|
-
steps.push({
|
|
330
|
-
title: 'let the revision write its own data, but not the manifests in it',
|
|
331
|
-
argv: [
|
|
332
|
-
'storage',
|
|
333
|
-
'buckets',
|
|
334
|
-
'add-iam-policy-binding',
|
|
335
|
-
`gs://${bucket}`,
|
|
336
|
-
'--member',
|
|
337
|
-
`serviceAccount:${serviceAccount}`,
|
|
338
|
-
// objectAdmin, not objectViewer: state, the log, attachments, memory
|
|
339
|
-
// and skills are all written by the running endpoint.
|
|
340
|
-
'--role',
|
|
341
|
-
'roles/storage.objectAdmin',
|
|
342
|
-
'--condition',
|
|
343
|
-
`title=owns-its-data,expression=${objectsUnder('data/')}${providerManifests ? ` && !${providerManifests}` : ''}`,
|
|
344
|
-
],
|
|
345
|
-
tolerateFailure: true,
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
steps.push({
|
|
349
|
-
title: 'let the revision read its config, and only read it',
|
|
350
|
-
argv: [
|
|
351
|
-
'storage',
|
|
352
|
-
'buckets',
|
|
353
|
-
'add-iam-policy-binding',
|
|
354
|
-
`gs://${bucket}`,
|
|
355
|
-
'--member',
|
|
356
|
-
`serviceAccount:${serviceAccount}`,
|
|
357
|
-
'--role',
|
|
358
|
-
'roles/storage.objectViewer',
|
|
359
|
-
// `expression=true` was here, which is every object in the bucket —
|
|
360
|
-
// the step title and ADR-023 both claim a narrowing this did not do.
|
|
361
|
-
// The config the revision reads is the workspace file, the profiles
|
|
362
|
-
// beside it, and each profile's own manifests, so name exactly those.
|
|
363
|
-
'--condition',
|
|
364
|
-
`title=reads-its-config,expression=${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')}${providerManifests ? ` || ${providerManifests}` : ''}`,
|
|
365
|
-
],
|
|
366
|
-
tolerateFailure: true,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
366
|
+
serviceAccount,
|
|
367
|
+
profiles: input.profiles ?? [],
|
|
368
|
+
policy,
|
|
369
|
+
})),
|
|
370
|
+
);
|
|
369
371
|
}
|
|
370
372
|
|
|
371
|
-
return
|
|
373
|
+
return steps;
|
|
372
374
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { recordTarget, resolveWorkspaceRoot, type TargetConfig } from '#profile';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Writing down where a deployment lives, and what rolled it.
|
|
5
|
+
*
|
|
6
|
+
* **The target hands itself over to the workspace it now lives in.** Two writes,
|
|
7
|
+
* and the order matters. The declaration goes into the bucket's own
|
|
8
|
+
* `lanes-link.yaml` first, because that is the file the revision coming up will
|
|
9
|
+
* read to learn where its credentials and bytes are — and a revision that boots
|
|
10
|
+
* before it lands has nothing to open.
|
|
11
|
+
*
|
|
12
|
+
* The local entry then becomes a *pointer*. That is the whole of ADR-052 in two
|
|
13
|
+
* lines: after this, exactly one file declares this target, and this machine
|
|
14
|
+
* holds a reference to it rather than a copy. ADR-044's index existed because
|
|
15
|
+
* the profile's own block was the only record and could be lost in one edit;
|
|
16
|
+
* there is no second copy left to lose.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface DeploymentRecord {
|
|
20
|
+
/** Where the target's own workspace lives — the bucket, for a deployment. */
|
|
21
|
+
readonly workspace: string;
|
|
22
|
+
readonly target: string;
|
|
23
|
+
readonly declared: TargetConfig;
|
|
24
|
+
/** Whose bearer token opens the endpoint (ADR-009). */
|
|
25
|
+
readonly primary: string;
|
|
26
|
+
/** When this deploy ran, as an ISO instant. */
|
|
27
|
+
readonly at: string;
|
|
28
|
+
/**
|
|
29
|
+
* The CLI release that rolled the revision, written only once it has rolled.
|
|
30
|
+
*
|
|
31
|
+
* The image is built from the installed package, so the version planning the
|
|
32
|
+
* deploy *is* the version serving it — which makes this the one place either
|
|
33
|
+
* end can be asked "what is up there" without a running endpoint to ask. The
|
|
34
|
+
* bucket carries it as well as the laptop, so a second machine reading the
|
|
35
|
+
* registry learns it too.
|
|
36
|
+
*
|
|
37
|
+
* Absent on the write that happens before the rollout, and that asymmetry is
|
|
38
|
+
* the point: a build that fails must not leave a version recorded that never
|
|
39
|
+
* served a request. The declaration has to land first (a revision that boots
|
|
40
|
+
* without it has nothing to open); the version can only be true afterwards.
|
|
41
|
+
*/
|
|
42
|
+
readonly version?: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function recordDeployment(record: DeploymentRecord): Promise<void> {
|
|
46
|
+
const stamp = {
|
|
47
|
+
primary: record.primary,
|
|
48
|
+
last_deploy: record.at,
|
|
49
|
+
...(record.version ? { last_deploy_version: record.version } : {}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await recordTarget(record.workspace, record.target, { ...record.declared, ...stamp });
|
|
53
|
+
|
|
54
|
+
// **This machine's registry, not the target's.** The workspace a profile was
|
|
55
|
+
// *found* in is the bucket for a deployed target — so writing the pointer
|
|
56
|
+
// there pointed the bucket at itself, and the revision that came up refused to
|
|
57
|
+
// open its own target.
|
|
58
|
+
await recordTarget(resolveWorkspaceRoot(), record.target, {
|
|
59
|
+
workspace: record.workspace,
|
|
60
|
+
...stamp,
|
|
61
|
+
});
|
|
62
|
+
}
|
package/src/deployments/steps.ts
CHANGED
|
@@ -60,6 +60,36 @@ export function isAlreadyThere(stderr: string): boolean {
|
|
|
60
60
|
return EXISTS.test(stderr);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The same thing, for a step that takes something away.
|
|
65
|
+
*
|
|
66
|
+
* A deploy removes the IAM bindings it supersedes, and a binding that is already
|
|
67
|
+
* gone is that step succeeding: the second deploy after the one that removed it
|
|
68
|
+
* finds nothing to remove, exactly as the second deploy after a create finds the
|
|
69
|
+
* thing present. Without this it reads as a warning, and a successful deploy
|
|
70
|
+
* printing warnings for its expected case teaches an operator to skim past the
|
|
71
|
+
* output that matters.
|
|
72
|
+
*/
|
|
73
|
+
const GONE = /NOT_FOUND|not found|does not exist|no such|cannot find|HTTPError 404/i;
|
|
74
|
+
|
|
75
|
+
export function isAlreadyGone(stderr: string): boolean {
|
|
76
|
+
return GONE.test(stderr);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a tolerated failure was, when it was this step's expected case.
|
|
81
|
+
*
|
|
82
|
+
* Keyed on what the step *does*, not on the message alone. `NOT_FOUND` from a
|
|
83
|
+
* removal is the work already being done; the same word from a step that binds a
|
|
84
|
+
* role means the secret it was binding does not exist, which is a deploy rolling
|
|
85
|
+
* a revision that cannot read its own token — the failure that has to stay
|
|
86
|
+
* visible.
|
|
87
|
+
*/
|
|
88
|
+
export function expectedOutcome(step: DeployStep, stderr: string): string | null {
|
|
89
|
+
if (step.removes === true) return isAlreadyGone(stderr) ? 'already gone' : null;
|
|
90
|
+
return isAlreadyThere(stderr) ? 'already there' : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
63
93
|
/**
|
|
64
94
|
* Spent across the whole run rather than per step.
|
|
65
95
|
*
|
|
@@ -112,11 +142,12 @@ export async function runSteps(
|
|
|
112
142
|
if (step.tolerateFailure) {
|
|
113
143
|
// The expected case on every deploy after the first, said as such.
|
|
114
144
|
// Anything else it tolerated is still worth seeing, because a tolerated
|
|
115
|
-
// failure that is not
|
|
116
|
-
// with no service account and finds out several minutes later.
|
|
145
|
+
// failure that is not this step's expected case is how a deploy rolls a
|
|
146
|
+
// revision with no service account and finds out several minutes later.
|
|
147
|
+
const expected = expectedOutcome(step, result.stderr);
|
|
117
148
|
print(
|
|
118
|
-
|
|
119
|
-
? style.dim(` ${style.green('=')}
|
|
149
|
+
expected !== null
|
|
150
|
+
? style.dim(` ${style.green('=')} ${expected}`)
|
|
120
151
|
: warn(` ${firstLine(result.stderr)}`),
|
|
121
152
|
);
|
|
122
153
|
continue;
|
|
@@ -63,6 +63,9 @@ function pick(previous: WorkspaceTarget | undefined): Partial<WorkspaceTarget> {
|
|
|
63
63
|
return {
|
|
64
64
|
...(previous.primary ? { primary: previous.primary } : {}),
|
|
65
65
|
...(previous.last_deploy ? { last_deploy: previous.last_deploy } : {}),
|
|
66
|
+
...(previous.last_deploy_version
|
|
67
|
+
? { last_deploy_version: previous.last_deploy_version }
|
|
68
|
+
: {}),
|
|
66
69
|
};
|
|
67
70
|
}
|
|
68
71
|
|
package/src/profile/registry.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface ResolvedTarget {
|
|
|
42
42
|
readonly workspaceRoot: string;
|
|
43
43
|
/** The adapter set, from whichever workspace declares it. */
|
|
44
44
|
readonly declared: TargetConfig;
|
|
45
|
-
/** The declaring entry, for `primary` and
|
|
45
|
+
/** The declaring entry, for `primary` and the deploy record. */
|
|
46
46
|
readonly entry: WorkspaceTarget;
|
|
47
47
|
/** Whether the local workspace reached this through a pointer. */
|
|
48
48
|
readonly remote: boolean;
|
|
@@ -107,10 +107,11 @@ export async function openTarget(root: string, target: string): Promise<Resolved
|
|
|
107
107
|
const declared = declaredTarget(remoteEntry);
|
|
108
108
|
if (!declared) throw incompleteTarget(target, workspaceRoot);
|
|
109
109
|
|
|
110
|
-
// The local entry's `primary` and `
|
|
111
|
-
// machine that ran it; the declaring workspace is
|
|
112
|
-
// else. Merged this way round so a redeploy from a
|
|
113
|
-
// silently lose the first one's record of who opens the
|
|
110
|
+
// The local entry's `primary`, `last_deploy` and `last_deploy_version` are what
|
|
111
|
+
// `deploy` wrote on the machine that ran it; the declaring workspace is
|
|
112
|
+
// authoritative for everything else. Merged this way round so a redeploy from a
|
|
113
|
+
// second machine does not silently lose the first one's record of who opens the
|
|
114
|
+
// endpoint.
|
|
114
115
|
return {
|
|
115
116
|
target,
|
|
116
117
|
workspaceRoot,
|
package/src/profile/schema.ts
CHANGED
|
@@ -433,6 +433,20 @@ export const workspaceTargetSchema = z
|
|
|
433
433
|
*/
|
|
434
434
|
primary: identifier.optional(),
|
|
435
435
|
last_deploy: z.string().optional(),
|
|
436
|
+
/**
|
|
437
|
+
* The CLI release that rolled the revision serving this target.
|
|
438
|
+
*
|
|
439
|
+
* Written by `deploy`, after the rollout rather than before it, so a build
|
|
440
|
+
* that failed does not leave a version recorded that never served anything.
|
|
441
|
+
* The image is built from the installed package, so the CLI that ran the
|
|
442
|
+
* deploy is the code running up there — which makes this the only way to ask
|
|
443
|
+
* "what version is the endpoint" without an endpoint answering.
|
|
444
|
+
*
|
|
445
|
+
* In the target's own workspace as well as on the machine that deployed it:
|
|
446
|
+
* a second laptop reading the registry learns it too, and `target show`
|
|
447
|
+
* prints it beside `last_deploy`.
|
|
448
|
+
*/
|
|
449
|
+
last_deploy_version: z.string().optional(),
|
|
436
450
|
})
|
|
437
451
|
.superRefine((entry, ctx) => {
|
|
438
452
|
const declares = entry.credentials !== undefined || entry.storage !== undefined;
|