@lanes-sh/link 0.6.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -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';
@@ -157,6 +158,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
157
158
  target,
158
159
  rotatable,
159
160
  readable,
161
+ profiles: serving,
160
162
  });
161
163
 
162
164
  // Where the running instance will read its config. The bucket the target
@@ -185,7 +187,10 @@ export async function deploy(flags: DeployFlags): Promise<void> {
185
187
  printSteps(driver, [...provision, ...rollout]);
186
188
  print('');
187
189
  if (workspace) print(style.dim(` the workspace would be uploaded to ${workspace}`));
188
- print(style.dim(' --dry-run: nothing was run, and no credential was read or written.'));
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.'));
189
194
  return;
190
195
  }
191
196
 
@@ -260,6 +265,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
260
265
  // uploaded is a profile that gets served, and repairing a narrower set than
261
266
  // the upload sends would leave a served profile without the surface — this
262
267
  // bug again, one profile over.
268
+ let recorded: DeploymentRecord | null = null;
263
269
  if (workspace) {
264
270
  // The pre-flight that used to sit here is gone with contract 1. It refused
265
271
  // a deploy carrying a profile that did not declare the target, because the
@@ -295,40 +301,26 @@ export async function deploy(flags: DeployFlags): Promise<void> {
295
301
  // listing and no writes.
296
302
  await migrateWorkspace(workspace, { apply: true });
297
303
 
298
- // **The target hands itself over to the workspace it now lives in.**
299
- //
300
- // Two writes, and the order matters. The declaration goes into the bucket's
301
- // own `lanes-link.yaml` first, because that is the file the revision coming
302
- // up will read to learn where its credentials and bytes are — and a
303
- // revision that boots before it lands has nothing to open.
304
- //
305
- // The local entry then becomes a *pointer*. That is the whole of ADR-052 in
306
- // two lines: after this, exactly one file declares this target, and this
307
- // machine holds a reference to it rather than a copy. ADR-044's index
308
- // existed because the profile's own block was the only record and could be
309
- // lost in one edit; there is no second copy left to lose.
310
- const stamped = new Date().toISOString();
311
-
312
- await recordTarget(workspace, target, {
313
- ...declared,
314
- primary: resolution.profile,
315
- last_deploy: stamped,
316
- });
317
-
318
- // **This machine's registry, not the target's.** `resolution.workspaceRoot`
319
- // is the workspace the profile was *found* in, which for a deployed target is
320
- // the bucket — so writing the pointer there pointed the bucket at itself, and
321
- // the revision that came up refused to open its own target.
322
- 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 = {
323
308
  workspace,
309
+ target,
310
+ declared,
324
311
  primary: resolution.profile,
325
- last_deploy: stamped,
326
- });
312
+ at: new Date().toISOString(),
313
+ };
314
+ await recordDeployment(recorded);
327
315
  }
328
316
 
329
317
  heading('Rolling out');
330
318
  await runSteps(driver, rollout);
331
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
+
332
324
  const url = await driver.url(deployConfig);
333
325
  if (!url) {
334
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 {
@@ -92,6 +103,20 @@ export interface ProvisionInput {
92
103
  * what a target with no profile to walk still needs.
93
104
  */
94
105
  readonly readable?: readonly string[];
106
+ /**
107
+ * The profiles this revision serves, so the bucket conditions can name each
108
+ * one's provider manifests.
109
+ *
110
+ * Needed because Cloud Storage IAM conditions cannot express "any profile
111
+ * segment": their CEL is a restricted subset with no `matches`, so the only
112
+ * way to carve out `data/<profile>/providers.d/` is to enumerate the profiles
113
+ * and write one `startsWith` each. `deploy.ts` already resolved the list.
114
+ *
115
+ * Absent leaves the carve-out off entirely rather than guessing, which is the
116
+ * safe direction: the revision keeps write on its own data and the manifests
117
+ * inside it, exactly as it did before the carve-out existed.
118
+ */
119
+ readonly profiles?: readonly string[];
95
120
  }
96
121
 
97
122
  export interface SurveyInput {
@@ -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
- return provisionSteps(input);
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,7 +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 { bucketSteps } from './bucket.ts';
4
5
  import { requireProject } from './gcloud.ts';
6
+ import type { PolicyReader } from './iam.ts';
5
7
 
6
8
  /**
7
9
  * The project-level things a Cloud Run deploy needs to already exist.
@@ -55,6 +57,44 @@ export interface SecretGrant {
55
57
  readonly refs: readonly SecretRef[];
56
58
  }
57
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
+
58
98
  /** Read, named one secret at a time, so the grant needs no condition to be scoped. */
59
99
  export function readSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
60
100
  return refs.map((ref) => ({
@@ -79,39 +119,32 @@ export function readSteps({ project, serviceAccount, refs }: SecretGrant): Deplo
79
119
  }
80
120
 
81
121
  /**
82
- * Write, two steps each, and the first is what keeps the second narrow: the
83
- * secret is created here so the revision only ever needs to *add a version*,
84
- * never `secrets.create`, which is a project-level permission that would let it
85
- * 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".
86
124
  */
87
125
  export function rotateSteps({ project, serviceAccount, refs }: SecretGrant): DeployStep[] {
88
- return refs.flatMap((ref) => {
89
- const id = encodeRef(ref);
90
- return [
91
- {
92
- title: `create the secret ${id}, so the revision never needs secrets.create`,
93
- argv: ['secrets', 'create', id, '--project', project, '--replication-policy', 'automatic'],
94
- tolerateFailure: true,
95
- },
96
- {
97
- title: `let the revision rewrite ${ref}, and nothing else in the store`,
98
- argv: [
99
- 'secrets',
100
- 'add-iam-policy-binding',
101
- id,
102
- '--project',
103
- project,
104
- '--member',
105
- `serviceAccount:${serviceAccount}`,
106
- '--role',
107
- 'roles/secretmanager.secretVersionAdder',
108
- '--condition',
109
- 'None',
110
- ],
111
- tolerateFailure: true,
112
- },
113
- ];
114
- });
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();
115
148
  }
116
149
 
117
150
  /**
@@ -141,12 +174,74 @@ export function secretGrantSteps(
141
174
  ): DeployStep[] {
142
175
  const { project, serviceAccount } = grant;
143
176
  return [
177
+ ...createSteps({ project, refs: union(grant.readable, grant.rotatable) }),
144
178
  ...readSteps({ project, serviceAccount, refs: grant.readable }),
145
179
  ...rotateSteps({ project, serviceAccount, refs: grant.rotatable }),
146
180
  ];
147
181
  }
148
182
 
149
- export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
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[]> {
150
245
  const cloudrun = requireProject(input.deploy, input.target);
151
246
  const { project, region, service_account: serviceAccount } = cloudrun;
152
247
  const steps: DeployStep[] = [];
@@ -205,6 +300,25 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
205
300
  tolerateFailure: true,
206
301
  });
207
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
+
208
322
  // Read, named one secret at a time, for the same reason the write side is:
209
323
  // a resource-level grant needs no condition to be scoped.
210
324
  //
@@ -219,37 +333,19 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
219
333
  // Affordable because the serving path reads by explicit ref: `list()` is a
220
334
  // CLI call, and `secretAccessor` never carried `secrets.list` anyway.
221
335
  // `readableRefs` derives the set from config and manifests at deploy time.
222
- steps.push(...readSteps({ project, serviceAccount, refs: input.readable ?? [] }));
223
- }
336
+ const readable = input.readable ?? [];
224
337
 
225
- // What a revision rewrites in its own credential store, named one secret at a
226
- // time. Two kinds, and they arrive from different places:
227
- //
228
- // - the vault document, because `vault.put` is a capability an agent may
229
- // hold under policy (ADR-022);
230
- // - each connection's OAuth token, because a refresh persists and serving a
231
- // request is what triggers it (ADR-026).
232
- //
233
- // The second was missing, and the shape of the miss is worth keeping in mind:
234
- // nothing here was wrong, it was incomplete, and being incomplete looked
235
- // exactly like being finished. Reading mail 403'd an hour after every deploy.
236
- //
237
- // Two steps each, and the first is what keeps the second narrow: the secret is
238
- // created here so the revision only ever needs to *add a version*, never
239
- // `secrets.create`, which is a project-level permission that would let it mint
240
- // credential refs of its own. The binding is on the one secret, so it needs no
241
- // condition to be scoped — a resource-level grant already is.
242
- const writable = serviceAccount
243
- ? [
244
- ...(input.declared.vault?.adapter === 'secret'
245
- ? [input.declared.vault.ref ?? VAULT_DOCUMENT_REF]
246
- : []),
247
- ...(input.rotatable ?? []),
248
- ]
249
- : [];
250
-
251
- if (serviceAccount) {
252
- 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
+ );
253
349
  }
254
350
 
255
351
  // Any target that addresses a bucket, which deployed means all of them:
@@ -262,98 +358,17 @@ export function provisionSteps(input: ProvisionInput): Promise<DeployStep[]> {
262
358
  input.declared.storage.adapter === 'gcs' || input.declared.storage.adapter === 's3';
263
359
  const bucket = usesBucket ? input.declared.storage.bucket : undefined;
264
360
  if (bucket) {
265
- steps.push({
266
- title: `create the bucket gs://${bucket}`,
267
- argv: [
268
- 'storage',
269
- 'buckets',
270
- 'create',
271
- `gs://${bucket}`,
272
- '--project',
361
+ steps.push(
362
+ ...(await bucketSteps({
363
+ bucket,
273
364
  project,
274
- '--location',
275
365
  region,
276
- // Blobs here are read and written by one instance at a time and never
277
- // served publicly; uniform access removes per-object ACLs as a way to
278
- // get that wrong.
279
- '--uniform-bucket-level-access',
280
- ],
281
- tolerateFailure: true,
282
- });
283
-
284
- if (serviceAccount) {
285
- // Two conditioned bindings rather than one blanket objectAdmin, because
286
- // the bucket now holds the config as well as the data.
287
- //
288
- // ADR-007 says a deployed instance never mutates its own configuration.
289
- // That used to be enforced by the image being read-only, which stopped
290
- // being true when the workspace moved into the bucket (ADR-023). This is
291
- // where the guarantee went: the revision may write what it owns and may
292
- // only read what declares what it is.
293
- const objectsUnder = (path: string): string =>
294
- `resource.name.startsWith("projects/_/buckets/${bucket}/objects/${path}")`;
295
- const objectIs = (path: string): string =>
296
- `resource.name == "projects/_/buckets/${bucket}/objects/${path}"`;
297
-
298
- // A provider manifest is configuration that happens to live inside the
299
- // profile's directory (ADR-030), so `data/` alone no longer separates
300
- // what the revision owns from what declares what it is. Anchored to the
301
- // profile segment rather than matched loosely: `contains("/providers.d/")`
302
- // would also catch a blob whose own key happened to spell it.
303
- //
304
- // The dot is a character class, not `\.`, and that is the fix rather than
305
- // a style: this string is a *CEL string literal* holding a regex, so it is
306
- // unescaped once by CEL before the regex engine ever sees it. `\.` is not
307
- // a CEL escape sequence, so the whole expression failed to compile —
308
- // `token recognition error at: '"^projects/_/buckets/...providers\.'` —
309
- // and both bindings below carry `tolerateFailure`, so a deploy printed two
310
- // warnings and carried on with the scoping silently not applied. `[.]` is
311
- // the same regex and survives a layer of string unescaping unchanged,
312
- // which is what keeps the next person from reintroducing it.
313
- const providerManifests =
314
- `resource.name.matches("^projects/_/buckets/${bucket}/objects/data/[^/]+/providers[.]d/")`;
315
-
316
- steps.push({
317
- title: 'let the revision write its own data, but not the manifests in it',
318
- argv: [
319
- 'storage',
320
- 'buckets',
321
- 'add-iam-policy-binding',
322
- `gs://${bucket}`,
323
- '--member',
324
- `serviceAccount:${serviceAccount}`,
325
- // objectAdmin, not objectViewer: state, the log, attachments, memory
326
- // and skills are all written by the running endpoint.
327
- '--role',
328
- 'roles/storage.objectAdmin',
329
- '--condition',
330
- `title=owns-its-data,expression=${objectsUnder('data/')} && !${providerManifests}`,
331
- ],
332
- tolerateFailure: true,
333
- });
334
-
335
- steps.push({
336
- title: 'let the revision read its config, and only read it',
337
- argv: [
338
- 'storage',
339
- 'buckets',
340
- 'add-iam-policy-binding',
341
- `gs://${bucket}`,
342
- '--member',
343
- `serviceAccount:${serviceAccount}`,
344
- '--role',
345
- 'roles/storage.objectViewer',
346
- // `expression=true` was here, which is every object in the bucket —
347
- // the step title and ADR-023 both claim a narrowing this did not do.
348
- // The config the revision reads is the workspace file, the profiles
349
- // beside it, and each profile's own manifests, so name exactly those.
350
- '--condition',
351
- `title=reads-its-config,expression=${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')} || ${providerManifests}`,
352
- ],
353
- tolerateFailure: true,
354
- });
355
- }
366
+ serviceAccount,
367
+ profiles: input.profiles ?? [],
368
+ policy,
369
+ })),
370
+ );
356
371
  }
357
372
 
358
- return Promise.resolve(steps);
373
+ return steps;
359
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
+ }
@@ -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 "already there" is how a deploy rolls a revision
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
- isAlreadyThere(result.stderr)
119
- ? style.dim(` ${style.green('=')} already there`)
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
 
@@ -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 `last_deploy`. */
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 `last_deploy` are what `deploy` wrote on the
111
- // machine that ran it; the declaring workspace is authoritative for everything
112
- // else. Merged this way round so a redeploy from a second machine does not
113
- // silently lose the first one's record of who opens the endpoint.
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,
@@ -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;