@lanes-sh/link 0.6.0 → 0.6.2
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/operate/migrate.ts +13 -4
- package/src/cli/migrate-plan.ts +160 -0
- package/src/cli/workspace-migrate.ts +2 -124
- package/src/deployments/deploy.ts +80 -16
- package/src/deployments/gcp/driver.ts +13 -3
- package/src/deployments/upload.ts +9 -1
- package/src/profile/deployments.ts +8 -5
package/package.json
CHANGED
|
@@ -42,16 +42,24 @@ export async function migratedRenamedProviders(
|
|
|
42
42
|
): Promise<boolean> {
|
|
43
43
|
if (!(refusal instanceof ConfigError)) return false;
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// **The target's workspace, not this machine's.** `resolveSelection` defaults
|
|
46
|
+
// to the local root, which is where this looked before ADR-052 — and there was
|
|
47
|
+
// only one place a profile could be. The row that needs renaming is in the
|
|
48
|
+
// file the *named target* holds, so a bucket's stale row was invisible from
|
|
49
|
+
// here while the refusal kept naming this command as the fix.
|
|
50
|
+
const target = flags.target ?? '';
|
|
51
|
+
const localRoot = resolveWorkspaceRoot();
|
|
52
|
+
const root = await resolveTargetWorkspace(localRoot, target).catch(() => localRoot);
|
|
53
|
+
|
|
54
|
+
const selection = await resolveSelection({ profileFlag: flags.profile, root });
|
|
55
|
+
const document = await ConfigDocument.open(root, selection.profile);
|
|
47
56
|
if (pendingRenames(document).length === 0) return false;
|
|
48
57
|
|
|
49
58
|
// Shape-only, because the check this document fails runs after the schema.
|
|
50
59
|
// Throws when it is malformed beyond a rename, which is a better sentence
|
|
51
60
|
// than the referential one it would otherwise be reported under.
|
|
52
61
|
const config = shapeOf(document);
|
|
53
|
-
const
|
|
54
|
-
const credentials = await openSecretStoreFor(config, selection.workspaceRoot, target);
|
|
62
|
+
const credentials = await openSecretStoreFor(config, root, target);
|
|
55
63
|
|
|
56
64
|
const migration = await migrateRenamedProviders(document, credentials, {
|
|
57
65
|
apply: flags.fix === true,
|
|
@@ -70,6 +78,7 @@ export async function migratedRenamedProviders(
|
|
|
70
78
|
ok: applied && migration.blocked.length === 0,
|
|
71
79
|
profile: selection.profile,
|
|
72
80
|
target,
|
|
81
|
+
workspace: root,
|
|
73
82
|
applied,
|
|
74
83
|
rows: migration.rows,
|
|
75
84
|
changes: migration.changes,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
|
|
2
|
+
import { deployedWorkspace } from '#deployments/upload.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What one contract-1 target block becomes, and where.
|
|
6
|
+
*
|
|
7
|
+
* Apart from `workspace-migrate.ts`, which reads and writes: this is the
|
|
8
|
+
* decision, and it is the half that has been wrong twice. Both times the shape
|
|
9
|
+
* was right and the *perspective* was not — the same `cloud:` block is a pointer
|
|
10
|
+
* seen from a laptop and a declaration seen from inside the bucket, and a
|
|
11
|
+
* migration that cannot tell which end it is on writes `gs://b` pointing at
|
|
12
|
+
* `gs://b`.
|
|
13
|
+
*
|
|
14
|
+
* So the workspace being migrated is an argument to every function here, and the
|
|
15
|
+
* tests drive them directly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every profile's target blocks, folded into one registry.
|
|
20
|
+
*
|
|
21
|
+
* Two refusals rather than guesses, because both are cases where one target
|
|
22
|
+
* would need two different answers and picking either silently is the class of
|
|
23
|
+
* bug this whole change is about.
|
|
24
|
+
*/
|
|
25
|
+
export async function hoist(
|
|
26
|
+
legacy: readonly { profile: string; targets: Record<string, LegacyTarget> }[],
|
|
27
|
+
workspaceRoot: string,
|
|
28
|
+
): Promise<Record<string, WorkspaceTarget>> {
|
|
29
|
+
const registry: Record<string, WorkspaceTarget> = {};
|
|
30
|
+
const seenFrom: Record<string, string> = {};
|
|
31
|
+
|
|
32
|
+
for (const { profile, targets } of legacy) {
|
|
33
|
+
for (const [name, declared] of Object.entries(targets)) {
|
|
34
|
+
const entry = toEntry(name, declared, profile, workspaceRoot);
|
|
35
|
+
if (entry === null) continue;
|
|
36
|
+
const previous = registry[name];
|
|
37
|
+
|
|
38
|
+
if (previous === undefined) {
|
|
39
|
+
registry[name] = entry;
|
|
40
|
+
seenFrom[name] = profile;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (JSON.stringify(previous) !== JSON.stringify(entry)) {
|
|
45
|
+
throw new ConfigError(
|
|
46
|
+
`Profiles "${seenFrom[name]}" and "${profile}" both declare target "${name}", and they ` +
|
|
47
|
+
`do not agree.\n` +
|
|
48
|
+
` A target is declared once by the workspace it lives in (ADR-052), so one of these\n` +
|
|
49
|
+
` has to win and this cannot pick.\n\n` +
|
|
50
|
+
` ${seenFrom[name]}: ${summarise(previous)}\n` +
|
|
51
|
+
` ${profile}: ${summarise(entry)}\n\n` +
|
|
52
|
+
` Edit one of them to match the other, then run this again.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return registry;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One contract-1 target block as a registry entry.
|
|
63
|
+
*
|
|
64
|
+
* A block whose storage names a bucket becomes a pointer: that bucket is a
|
|
65
|
+
* workspace, it holds the profiles served there, and under ADR-052 it is the
|
|
66
|
+
* thing that declares the target. The adapters are not copied into the pointer —
|
|
67
|
+
* they travel to the bucket on the next `deploy`, which is the only command that
|
|
68
|
+
* can write there and roll an image that understands what it wrote.
|
|
69
|
+
*
|
|
70
|
+
* Per-profile paths are dropped when they are the layout defaults, which is the
|
|
71
|
+
* ordinary case: `./data/<profile>` and `./data/<profile>/credentials.enc` are
|
|
72
|
+
* exactly what `layout.ts` derives, so a workspace-level target that omits them
|
|
73
|
+
* addresses the same bytes. A genuinely custom path cannot be hoisted — one
|
|
74
|
+
* target cannot hold a different path per profile — and is refused by name.
|
|
75
|
+
*/
|
|
76
|
+
export function toEntry(
|
|
77
|
+
name: string,
|
|
78
|
+
declared: LegacyTarget,
|
|
79
|
+
profile: string,
|
|
80
|
+
workspaceRoot: string,
|
|
81
|
+
): WorkspaceTarget | null {
|
|
82
|
+
const remote = deployedWorkspace(declared);
|
|
83
|
+
|
|
84
|
+
// **A target whose bucket is the workspace being migrated declares itself.**
|
|
85
|
+
//
|
|
86
|
+
// Migrating happens on both ends, and the second one is inside the bucket: the
|
|
87
|
+
// profile there carries the same `cloud` block, and deriving a pointer from it
|
|
88
|
+
// produces `gs://b` pointing at `gs://b`. That is a loop, and `openTarget`
|
|
89
|
+
// refuses it — which made `deploy` unable to run against the bucket it had
|
|
90
|
+
// just migrated, on the one command the refusal names as the fix.
|
|
91
|
+
if (remote && remote !== workspaceRoot) return { workspace: remote };
|
|
92
|
+
|
|
93
|
+
// **A filesystem target is dropped from a remote workspace.**
|
|
94
|
+
//
|
|
95
|
+
// The bucket's copy of a profile was uploaded from a laptop, so it carries
|
|
96
|
+
// that laptop's `local:` block — paths under `./data/` that address a disk the
|
|
97
|
+
// endpoint has never seen. Hoisting it would leave the bucket declaring a
|
|
98
|
+
// target it can never open, and `workspacePath` refuses that combination the
|
|
99
|
+
// moment anything tries.
|
|
100
|
+
//
|
|
101
|
+
// Nothing is lost: the machine that owns `local` has its own copy, and that is
|
|
102
|
+
// the one that was ever real.
|
|
103
|
+
if (isRemoteWorkspace(workspaceRoot) && declared.storage.adapter === 'filesystem') return null;
|
|
104
|
+
|
|
105
|
+
const storagePath = declared.storage.path;
|
|
106
|
+
const credentialsPath = declared.credentials.path;
|
|
107
|
+
|
|
108
|
+
const defaultStorage = `./${layout.blobs(profile)}`;
|
|
109
|
+
const defaultCredentials = `./${layout.credentials(profile)}`;
|
|
110
|
+
|
|
111
|
+
refuseCustomPath(name, profile, workspaceRoot, 'storage.path', storagePath, defaultStorage);
|
|
112
|
+
refuseCustomPath(
|
|
113
|
+
name,
|
|
114
|
+
profile,
|
|
115
|
+
workspaceRoot,
|
|
116
|
+
'credentials.path',
|
|
117
|
+
credentialsPath,
|
|
118
|
+
defaultCredentials,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const { path: _storage, ...storage } = declared.storage;
|
|
122
|
+
const { path: _credentials, ...credentials } = declared.credentials;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
credentials,
|
|
126
|
+
storage,
|
|
127
|
+
...(declared.audit ? { audit: declared.audit } : {}),
|
|
128
|
+
...(declared.vault ? { vault: declared.vault } : {}),
|
|
129
|
+
...(declared.deploy ? { deploy: declared.deploy } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function refuseCustomPath(
|
|
134
|
+
target: string,
|
|
135
|
+
profile: string,
|
|
136
|
+
workspaceRoot: string,
|
|
137
|
+
field: string,
|
|
138
|
+
value: string | undefined,
|
|
139
|
+
expected: string,
|
|
140
|
+
): void {
|
|
141
|
+
if (value === undefined) return;
|
|
142
|
+
// Both spellings of the same directory: `./data/personal` and `data/personal`
|
|
143
|
+
// resolve identically and the template has written each at different times.
|
|
144
|
+
if (value === expected || value === expected.replace(/^\.\//, '')) return;
|
|
145
|
+
|
|
146
|
+
throw new ConfigError(
|
|
147
|
+
`Profile "${profile}" declares target "${target}" with a custom ${field}:\n` +
|
|
148
|
+
` ${value}\n` +
|
|
149
|
+
` A target is declared once for the whole workspace now (ADR-052), so it cannot hold a\n` +
|
|
150
|
+
` different path per profile. The default it would get is ${expected}.\n\n` +
|
|
151
|
+
` Move the data there and delete the line, or keep this profile in a workspace of its\n` +
|
|
152
|
+
` own: LANES_LINK_HOME=${workspaceRoot}/<somewhere> lanes link profile add ${profile} --target ${target}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function summarise(entry: WorkspaceTarget): string {
|
|
157
|
+
if (entry.workspace !== undefined) return `points at ${entry.workspace}`;
|
|
158
|
+
const parts = [entry.credentials?.adapter, entry.storage?.adapter].filter(Boolean);
|
|
159
|
+
return `${parts.join(' + ')}${entry.deploy ? `, deploys ${entry.deploy.service}` : ''}`;
|
|
160
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
layout,
|
|
7
7
|
listProfiles,
|
|
8
8
|
readWorkspaceFile,
|
|
9
|
+
isRemoteWorkspace,
|
|
9
10
|
workspaceFiles,
|
|
10
11
|
isLegacyProfile,
|
|
11
12
|
legacyConfigSchema,
|
|
@@ -13,8 +14,8 @@ import {
|
|
|
13
14
|
type LegacyTarget,
|
|
14
15
|
type WorkspaceTarget,
|
|
15
16
|
} from '#profile';
|
|
16
|
-
import { deployedWorkspace } from '#deployments/upload.ts';
|
|
17
17
|
import { ConfigDocument } from './config-edit.ts';
|
|
18
|
+
import { hoist, summarise } from './migrate-plan.ts';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Contract 1 → 2: the target moves out of the profile and into the workspace.
|
|
@@ -168,123 +169,6 @@ export async function migrateWorkspace(
|
|
|
168
169
|
};
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
/**
|
|
172
|
-
* Every profile's target blocks, folded into one registry.
|
|
173
|
-
*
|
|
174
|
-
* Two refusals rather than guesses, because both are cases where one target
|
|
175
|
-
* would need two different answers and picking either silently is the class of
|
|
176
|
-
* bug this whole change is about.
|
|
177
|
-
*/
|
|
178
|
-
async function hoist(
|
|
179
|
-
legacy: readonly { profile: string; targets: Record<string, LegacyTarget> }[],
|
|
180
|
-
workspaceRoot: string,
|
|
181
|
-
): Promise<Record<string, WorkspaceTarget>> {
|
|
182
|
-
const registry: Record<string, WorkspaceTarget> = {};
|
|
183
|
-
const seenFrom: Record<string, string> = {};
|
|
184
|
-
|
|
185
|
-
for (const { profile, targets } of legacy) {
|
|
186
|
-
for (const [name, declared] of Object.entries(targets)) {
|
|
187
|
-
const entry = toEntry(name, declared, profile, workspaceRoot);
|
|
188
|
-
const previous = registry[name];
|
|
189
|
-
|
|
190
|
-
if (previous === undefined) {
|
|
191
|
-
registry[name] = entry;
|
|
192
|
-
seenFrom[name] = profile;
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (JSON.stringify(previous) !== JSON.stringify(entry)) {
|
|
197
|
-
throw new ConfigError(
|
|
198
|
-
`Profiles "${seenFrom[name]}" and "${profile}" both declare target "${name}", and they ` +
|
|
199
|
-
`do not agree.\n` +
|
|
200
|
-
` A target is declared once by the workspace it lives in (ADR-052), so one of these\n` +
|
|
201
|
-
` has to win and this cannot pick.\n\n` +
|
|
202
|
-
` ${seenFrom[name]}: ${summarise(previous)}\n` +
|
|
203
|
-
` ${profile}: ${summarise(entry)}\n\n` +
|
|
204
|
-
` Edit one of them to match the other, then run this again.`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return registry;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* One contract-1 target block as a registry entry.
|
|
215
|
-
*
|
|
216
|
-
* A block whose storage names a bucket becomes a pointer: that bucket is a
|
|
217
|
-
* workspace, it holds the profiles served there, and under ADR-052 it is the
|
|
218
|
-
* thing that declares the target. The adapters are not copied into the pointer —
|
|
219
|
-
* they travel to the bucket on the next `deploy`, which is the only command that
|
|
220
|
-
* can write there and roll an image that understands what it wrote.
|
|
221
|
-
*
|
|
222
|
-
* Per-profile paths are dropped when they are the layout defaults, which is the
|
|
223
|
-
* ordinary case: `./data/<profile>` and `./data/<profile>/credentials.enc` are
|
|
224
|
-
* exactly what `layout.ts` derives, so a workspace-level target that omits them
|
|
225
|
-
* addresses the same bytes. A genuinely custom path cannot be hoisted — one
|
|
226
|
-
* target cannot hold a different path per profile — and is refused by name.
|
|
227
|
-
*/
|
|
228
|
-
function toEntry(
|
|
229
|
-
name: string,
|
|
230
|
-
declared: LegacyTarget,
|
|
231
|
-
profile: string,
|
|
232
|
-
workspaceRoot: string,
|
|
233
|
-
): WorkspaceTarget {
|
|
234
|
-
const remote = deployedWorkspace(declared);
|
|
235
|
-
if (remote) return { workspace: remote };
|
|
236
|
-
|
|
237
|
-
const storagePath = declared.storage.path;
|
|
238
|
-
const credentialsPath = declared.credentials.path;
|
|
239
|
-
|
|
240
|
-
const defaultStorage = `./${layout.blobs(profile)}`;
|
|
241
|
-
const defaultCredentials = `./${layout.credentials(profile)}`;
|
|
242
|
-
|
|
243
|
-
refuseCustomPath(name, profile, workspaceRoot, 'storage.path', storagePath, defaultStorage);
|
|
244
|
-
refuseCustomPath(
|
|
245
|
-
name,
|
|
246
|
-
profile,
|
|
247
|
-
workspaceRoot,
|
|
248
|
-
'credentials.path',
|
|
249
|
-
credentialsPath,
|
|
250
|
-
defaultCredentials,
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
const { path: _storage, ...storage } = declared.storage;
|
|
254
|
-
const { path: _credentials, ...credentials } = declared.credentials;
|
|
255
|
-
|
|
256
|
-
return {
|
|
257
|
-
credentials,
|
|
258
|
-
storage,
|
|
259
|
-
...(declared.audit ? { audit: declared.audit } : {}),
|
|
260
|
-
...(declared.vault ? { vault: declared.vault } : {}),
|
|
261
|
-
...(declared.deploy ? { deploy: declared.deploy } : {}),
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function refuseCustomPath(
|
|
266
|
-
target: string,
|
|
267
|
-
profile: string,
|
|
268
|
-
workspaceRoot: string,
|
|
269
|
-
field: string,
|
|
270
|
-
value: string | undefined,
|
|
271
|
-
expected: string,
|
|
272
|
-
): void {
|
|
273
|
-
if (value === undefined) return;
|
|
274
|
-
// Both spellings of the same directory: `./data/personal` and `data/personal`
|
|
275
|
-
// resolve identically and the template has written each at different times.
|
|
276
|
-
if (value === expected || value === expected.replace(/^\.\//, '')) return;
|
|
277
|
-
|
|
278
|
-
throw new ConfigError(
|
|
279
|
-
`Profile "${profile}" declares target "${target}" with a custom ${field}:\n` +
|
|
280
|
-
` ${value}\n` +
|
|
281
|
-
` A target is declared once for the whole workspace now (ADR-052), so it cannot hold a\n` +
|
|
282
|
-
` different path per profile. The default it would get is ${expected}.\n\n` +
|
|
283
|
-
` Move the data there and delete the line, or keep this profile in a workspace of its\n` +
|
|
284
|
-
` own: LANES_LINK_HOME=${workspaceRoot}/<somewhere> lanes link profile add ${profile} --target ${target}`,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
172
|
/**
|
|
289
173
|
* Write the registry into the workspace file, creating it when absent.
|
|
290
174
|
*
|
|
@@ -351,12 +235,6 @@ function describe(
|
|
|
351
235
|
);
|
|
352
236
|
}
|
|
353
237
|
|
|
354
|
-
function summarise(entry: WorkspaceTarget): string {
|
|
355
|
-
if (entry.workspace !== undefined) return `points at ${entry.workspace}`;
|
|
356
|
-
const parts = [entry.credentials?.adapter, entry.storage?.adapter].filter(Boolean);
|
|
357
|
-
return `${parts.join(' + ')}${entry.deploy ? `, deploys ${entry.deploy.service}` : ''}`;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
238
|
/**
|
|
361
239
|
* Refuse a contract-1 workspace with the command that fixes it.
|
|
362
240
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ConfigError,
|
|
3
3
|
recordTarget,
|
|
4
|
+
resolveTargetWorkspace,
|
|
4
5
|
resolveWorkspaceRoot,
|
|
5
6
|
type DeployConfig,
|
|
6
7
|
} from '#profile';
|
|
@@ -61,6 +62,23 @@ export interface DeployFlags extends GlobalFlags {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export async function deploy(flags: DeployFlags): Promise<void> {
|
|
65
|
+
// **The target's own workspace is migrated before anything resolves it.**
|
|
66
|
+
//
|
|
67
|
+
// This is the first thing the command does, and it has to be. `deploy` is what
|
|
68
|
+
// the refusal on a contract-1 bucket tells you to run — and every other read of
|
|
69
|
+
// that bucket goes through `openTarget`, which refuses it for the same reason.
|
|
70
|
+
// Migrating after resolution made the instruction circular: the command named
|
|
71
|
+
// as the fix could not get past the problem it fixes.
|
|
72
|
+
//
|
|
73
|
+
// `resolveTargetWorkspace` is the one lookup that does not need the far end to
|
|
74
|
+
// declare anything: it reads this machine's pointer and stops. So the bucket is
|
|
75
|
+
// located, migrated, and only then opened.
|
|
76
|
+
//
|
|
77
|
+
// Idempotent, and silent on a workspace already at contract 2 — a listing and
|
|
78
|
+
// no writes. `--dry-run` reports what it would do and writes nothing, like
|
|
79
|
+
// every other step of this command.
|
|
80
|
+
if (!(await migrateTargetWorkspace(requireTargetFlag(flags), flags.dryRun !== true))) return;
|
|
81
|
+
|
|
64
82
|
// The one command allowed to name a target that does not exist yet: creating
|
|
65
83
|
// it is what a first deploy is for.
|
|
66
84
|
//
|
|
@@ -254,25 +272,28 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
254
272
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
255
273
|
// Uploading after would leave a window where the service is serving and the
|
|
256
274
|
// workspace it was told to read is not there yet.
|
|
257
|
-
|
|
275
|
+
// **Only when there is somewhere to copy from.** After ADR-052 the profiles
|
|
276
|
+
// a deployed target serves *live in* that target's workspace, so
|
|
277
|
+
// `resolution.workspaceRoot` and `workspace` are the same bucket and this is
|
|
278
|
+
// a copy onto itself. It ran, and the self-copy is how the bucket's registry
|
|
279
|
+
// came to be overwritten.
|
|
280
|
+
//
|
|
281
|
+
// What it is still for is the one-way trip: a first deploy, where the
|
|
282
|
+
// profile is on this machine and the bucket does not hold it yet. That is a
|
|
283
|
+
// move, not a sync — the next deploy finds it already there.
|
|
284
|
+
if (resolution.workspaceRoot !== workspace) {
|
|
285
|
+
await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
|
|
286
|
+
}
|
|
258
287
|
|
|
259
288
|
// **The bucket's own migration, here and nowhere else.**
|
|
260
289
|
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// After the upload, because the upload is what puts the profiles there for
|
|
268
|
-
// it to migrate. Idempotent, so a bucket already at contract 2 costs one
|
|
290
|
+
// A second pass, and it is not redundant. The one at the top of the command
|
|
291
|
+
// migrated whatever the bucket already held; this catches what the upload
|
|
292
|
+
// just put there — profiles from a workspace that is itself at contract 2
|
|
293
|
+
// arrive migrated, but a first deploy of a *newly created* bucket writes
|
|
294
|
+
// them here for the first time. Idempotent, so the ordinary case is one
|
|
269
295
|
// listing and no writes.
|
|
270
|
-
|
|
271
|
-
if (!migrated.alreadyCurrent) {
|
|
272
|
-
heading('Migrated');
|
|
273
|
-
for (const change of migrated.changes) print(` ${change}`);
|
|
274
|
-
print(style.dim(' The revision below is the first that can read it.'));
|
|
275
|
-
}
|
|
296
|
+
await migrateWorkspace(workspace, { apply: true });
|
|
276
297
|
|
|
277
298
|
// **The target hands itself over to the workspace it now lives in.**
|
|
278
299
|
//
|
|
@@ -294,7 +315,11 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
294
315
|
last_deploy: stamped,
|
|
295
316
|
});
|
|
296
317
|
|
|
297
|
-
|
|
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, {
|
|
298
323
|
workspace,
|
|
299
324
|
primary: resolution.profile,
|
|
300
325
|
last_deploy: stamped,
|
|
@@ -330,3 +355,42 @@ function requireTargetFlag(flags: DeployFlags): string {
|
|
|
330
355
|
}
|
|
331
356
|
return flags.target;
|
|
332
357
|
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Bring a target's own workspace to the current contract, before it is opened.
|
|
361
|
+
*
|
|
362
|
+
* Deliberately tolerant of a target this workspace has no pointer for: that is a
|
|
363
|
+
* first deploy, where there is nothing to migrate and `deploy` is about to create
|
|
364
|
+
* the workspace itself.
|
|
365
|
+
*
|
|
366
|
+
* Narrated when it does something. This rewrites every profile in somebody's
|
|
367
|
+
* bucket, and a command that reshapes that silently is one they cannot audit
|
|
368
|
+
* afterwards.
|
|
369
|
+
*/
|
|
370
|
+
async function migrateTargetWorkspace(target: string, apply: boolean): Promise<boolean> {
|
|
371
|
+
const root = resolveWorkspaceRoot();
|
|
372
|
+
|
|
373
|
+
const workspace = await resolveTargetWorkspace(root, target).catch(() => null);
|
|
374
|
+
if (workspace === null || workspace === root) return true;
|
|
375
|
+
|
|
376
|
+
const migrated = await migrateWorkspace(workspace, { apply });
|
|
377
|
+
if (migrated.alreadyCurrent) return true;
|
|
378
|
+
|
|
379
|
+
heading(apply ? 'Migrated' : 'Would migrate');
|
|
380
|
+
print(style.dim(` ${workspace}`));
|
|
381
|
+
for (const change of migrated.changes) print(` ${change}`);
|
|
382
|
+
if (apply) {
|
|
383
|
+
print(style.dim(' The revision this deploy rolls is the first that can read it.'));
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A dry run stops here rather than pressing on to survey and plan. Everything
|
|
388
|
+
// past this point opens the target, and the target is not readable until the
|
|
389
|
+
// migration above has actually happened — so continuing would report a second,
|
|
390
|
+
// confusing refusal about the thing the first paragraph just offered to fix.
|
|
391
|
+
print(style.dim(' Nothing was written, and nothing else was checked: the rest of this'));
|
|
392
|
+
print(style.dim(' command opens the target, which is not readable until this has run.'));
|
|
393
|
+
print('');
|
|
394
|
+
print(style.dim(` Run it for real: lanes link deploy --target ${target}`));
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
@@ -7,7 +7,8 @@ import type {
|
|
|
7
7
|
SurveyInput,
|
|
8
8
|
SurveyResult,
|
|
9
9
|
} from '../driver.ts';
|
|
10
|
-
import
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { installRoot, type DeployConfig } from '#profile';
|
|
11
12
|
import { encodeRef } from '../adapters/gcp-secret-manager.ts';
|
|
12
13
|
import {
|
|
13
14
|
captureGcloud,
|
|
@@ -50,6 +51,10 @@ export function deployPlan(input: PlanInput): DeployStep[] {
|
|
|
50
51
|
const cloudrun = requireProject(input.deploy, input.target);
|
|
51
52
|
const image = imageReference(cloudrun, input.tag);
|
|
52
53
|
const scope = ['--project', cloudrun.project, '--region', cloudrun.region];
|
|
54
|
+
// Where this package is installed, which is where the Dockerfile and the build
|
|
55
|
+
// config live. Never the working directory: `lanes link deploy` is run from
|
|
56
|
+
// wherever somebody happens to be standing.
|
|
57
|
+
const root = installRoot(import.meta.dir);
|
|
53
58
|
|
|
54
59
|
return [
|
|
55
60
|
{
|
|
@@ -80,10 +85,15 @@ export function deployPlan(input: PlanInput): DeployStep[] {
|
|
|
80
85
|
'--project',
|
|
81
86
|
cloudrun.project,
|
|
82
87
|
'--config',
|
|
83
|
-
'src/deployments/gcp/cloudbuild.yaml',
|
|
88
|
+
join(root, 'src/deployments/gcp/cloudbuild.yaml'),
|
|
84
89
|
'--substitutions',
|
|
85
90
|
`_IMAGE=${image}`,
|
|
86
|
-
|
|
91
|
+
// The build context, and the config beside it, are the *installed
|
|
92
|
+
// package* — not whatever directory the operator happened to run from.
|
|
93
|
+
// Both were relative, so `lanes link deploy` worked from a checkout and
|
|
94
|
+
// failed everywhere else with a missing cloudbuild.yaml, which reads as
|
|
95
|
+
// a broken install rather than as a wrong working directory.
|
|
96
|
+
root,
|
|
87
97
|
],
|
|
88
98
|
},
|
|
89
99
|
{
|
|
@@ -64,7 +64,15 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
|
|
|
64
64
|
* between matching it and not is a credential in a bucket.
|
|
65
65
|
*/
|
|
66
66
|
export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
|
|
67
|
-
|
|
67
|
+
// **Never the workspace file.** It was sent, and it is the one file that must
|
|
68
|
+
// not be: it holds the *target registry*, and the two workspaces have
|
|
69
|
+
// different ones. This machine's says `cloud: workspace: gs://…`, so copying
|
|
70
|
+
// it into that bucket left the bucket pointing at itself — a loop `openTarget`
|
|
71
|
+
// refuses, on the target it had just deployed (ADR-052).
|
|
72
|
+
//
|
|
73
|
+
// The bucket's own registry is written by `deploy`, from the declaration, once
|
|
74
|
+
// the upload is done.
|
|
75
|
+
if (key === WORKSPACE_FILE) return false;
|
|
68
76
|
|
|
69
77
|
// A set rather than one name, because a deploy now sends every profile that
|
|
70
78
|
// declares the target rather than the single one it was told. `undefined`
|
|
@@ -39,11 +39,14 @@ export async function recordTarget(
|
|
|
39
39
|
): Promise<void> {
|
|
40
40
|
await editRegistry(workspaceRoot, (targets) => {
|
|
41
41
|
const previous = targets[target];
|
|
42
|
+
// Neither shape merges into the other, and the symmetry is the point: an
|
|
43
|
+
// entry carrying both a `workspace:` and adapters is what the schema refuses.
|
|
44
|
+
//
|
|
42
45
|
// A pointer replacing a declaration is `deploy` handing the target over to
|
|
43
|
-
// the workspace it just wrote
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
// the workspace it just wrote. A declaration replacing a pointer is the same
|
|
47
|
+
// command writing the bucket's own registry — and merging there left the
|
|
48
|
+
// laptop's `workspace:` on it, pointing the bucket at itself.
|
|
49
|
+
targets[target] = { ...pick(previous), ...entry };
|
|
47
50
|
});
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -54,7 +57,7 @@ export async function removeTarget(workspaceRoot: string, target: string): Promi
|
|
|
54
57
|
});
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
/** The fields that survive
|
|
60
|
+
/** The fields that survive an entry changing shape: the deploy record, and only it. */
|
|
58
61
|
function pick(previous: WorkspaceTarget | undefined): Partial<WorkspaceTarget> {
|
|
59
62
|
if (!previous) return {};
|
|
60
63
|
return {
|