@lanes-sh/link 0.5.4 → 0.6.0
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/instructions/agents/lanes-link-scout.md +1 -1
- package/instructions/skills/lanes-link/SKILL.md +33 -29
- package/package.json +2 -1
- package/src/cli/commands/connect/target-note.ts +2 -2
- package/src/cli/commands/knowledge/index.ts +14 -34
- package/src/cli/commands/mcp/register.ts +1 -1
- package/src/cli/commands/operate/dashboard.ts +2 -2
- package/src/cli/commands/operate/inspect.ts +5 -1
- package/src/cli/commands/operate/migrate.ts +85 -1
- package/src/cli/commands/operate/outputs.ts +7 -22
- package/src/cli/commands/operate/status.ts +84 -69
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/profile/removal.ts +36 -25
- package/src/cli/commands/profile/remove.ts +5 -2
- package/src/cli/commands/profile.ts +56 -40
- package/src/cli/commands/sync.ts +94 -162
- package/src/cli/commands/target.ts +115 -74
- package/src/cli/commands/update.ts +56 -1
- package/src/cli/config-edit.ts +47 -16
- package/src/cli/endpoint-url.ts +3 -3
- package/src/cli/main.ts +9 -7
- package/src/cli/publish.ts +4 -2
- package/src/cli/runtime/open.ts +19 -10
- package/src/cli/runtime/select.ts +69 -22
- package/src/cli/selection-require.ts +79 -0
- package/src/cli/selection.ts +44 -92
- package/src/cli/workspace-migrate.ts +385 -0
- package/src/deployments/bootstrap.ts +31 -11
- package/src/deployments/deploy.ts +54 -27
- package/src/deployments/knowledge.ts +5 -2
- package/src/deployments/prepare.ts +3 -1
- package/src/deployments/serving.ts +19 -15
- package/src/deployments/upload.ts +10 -1
- package/src/profile/deployments.ts +64 -53
- package/src/profile/index.ts +22 -6
- package/src/profile/legacy.ts +92 -0
- package/src/profile/load.ts +12 -28
- package/src/profile/registry.ts +182 -0
- package/src/profile/schema.ts +162 -110
- package/src/profile/targets.ts +62 -91
- package/src/profile/testing.ts +78 -0
- package/src/profile/workspace.ts +11 -25
- package/src/server/dashboard.ts +4 -1
- package/src/server/harness.ts +1 -6
- package/src/cli/commands/profile/declare.ts +0 -154
- package/src/deployments/servable.ts +0 -82
- package/src/deployments/sync-apply.ts +0 -330
- package/src/deployments/sync.ts +0 -164
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
2
|
+
import {
|
|
3
|
+
ConfigError,
|
|
4
|
+
SUPPORTED_CONTRACT,
|
|
5
|
+
WORKSPACE_FILE,
|
|
6
|
+
layout,
|
|
7
|
+
listProfiles,
|
|
8
|
+
readWorkspaceFile,
|
|
9
|
+
workspaceFiles,
|
|
10
|
+
isLegacyProfile,
|
|
11
|
+
legacyConfigSchema,
|
|
12
|
+
writeWorkspaceFile,
|
|
13
|
+
type LegacyTarget,
|
|
14
|
+
type WorkspaceTarget,
|
|
15
|
+
} from '#profile';
|
|
16
|
+
import { deployedWorkspace } from '#deployments/upload.ts';
|
|
17
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Contract 1 → 2: the target moves out of the profile and into the workspace.
|
|
21
|
+
*
|
|
22
|
+
* Under contract 1 every profile carried a `targets:` block naming the adapter
|
|
23
|
+
* sets it could be opened against. That is what made a deploy leave two copies
|
|
24
|
+
* of each profile — one in `~/.lanes-link`, one in the bucket the endpoint reads
|
|
25
|
+
* — and gave them nothing to keep them honest. It failed the way it was always
|
|
26
|
+
* going to: a local file was rewritten, lost its cloud target and eight
|
|
27
|
+
* connections, and `status --target cloud` reported seven where the endpoint was
|
|
28
|
+
* serving fifteen. `sync targets` and ADR-044's deployment index were both
|
|
29
|
+
* written in response to earlier rounds of the same thing.
|
|
30
|
+
*
|
|
31
|
+
* ADR-052 removes the second copy. A workspace *is* a target: it declares its
|
|
32
|
+
* adapters once, in its own `lanes-link.yaml`, and holds the profiles that live
|
|
33
|
+
* in it. This is the one-way trip that gets an existing workspace there.
|
|
34
|
+
*
|
|
35
|
+
* **What it does, per workspace:** hoists each profile's target blocks into the
|
|
36
|
+
* workspace registry, strips `targets:` from the profile, and sets `contract: 2`.
|
|
37
|
+
* A block whose storage names a bucket becomes a *pointer* locally — the bucket
|
|
38
|
+
* declares it — and the adapters travel to that workspace when `deploy` next
|
|
39
|
+
* runs, which is the only command that can put them there safely.
|
|
40
|
+
*
|
|
41
|
+
* **Where it runs** follows `commands/operate/migrate.ts`'s reasoning: an
|
|
42
|
+
* operator should not have to know a migration command exists before their
|
|
43
|
+
* config breaks. So `update` runs it on the local workspace automatically,
|
|
44
|
+
* `doctor --fix` runs it on demand, and `deploy` runs it on the target workspace
|
|
45
|
+
* as its first step — which is what keeps a bucket and the image reading it in
|
|
46
|
+
* step, given contract 1 is not read at all.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export interface WorkspaceMigration {
|
|
50
|
+
readonly workspaceRoot: string;
|
|
51
|
+
/** Profiles rewritten to contract 2. */
|
|
52
|
+
readonly profiles: readonly string[];
|
|
53
|
+
/** Targets written into the registry, and how each was recorded. */
|
|
54
|
+
readonly targets: readonly { name: string; kind: 'declared' | 'pointer'; where?: string }[];
|
|
55
|
+
/** Human-readable lines, in the order they happened. */
|
|
56
|
+
readonly changes: readonly string[];
|
|
57
|
+
/** Nothing to do: every profile was already contract 2. */
|
|
58
|
+
readonly alreadyCurrent: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether this workspace still holds anything at contract 1. */
|
|
62
|
+
export async function needsMigration(workspaceRoot: string): Promise<boolean> {
|
|
63
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
64
|
+
const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), `profiles/${profile}.yaml`);
|
|
65
|
+
if (text === null) continue;
|
|
66
|
+
try {
|
|
67
|
+
if (isLegacyProfile(parseDocument(text).toJSON())) return true;
|
|
68
|
+
} catch {
|
|
69
|
+
// A file that will not parse is not this function's problem to report.
|
|
70
|
+
// `check` gives it a better sentence than "needs migrating" would.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Migrate one workspace. Safe to run on an already-migrated one.
|
|
78
|
+
*
|
|
79
|
+
* **Everything that can fail happens before the first write.** A refusal leaves
|
|
80
|
+
* the workspace exactly as it was rather than half-migrated, which matters more
|
|
81
|
+
* here than usual: the thing being rewritten is the only remaining description
|
|
82
|
+
* of where somebody's accounts live.
|
|
83
|
+
*/
|
|
84
|
+
export async function migrateWorkspace(
|
|
85
|
+
workspaceRoot: string,
|
|
86
|
+
options: { apply: boolean } = { apply: true },
|
|
87
|
+
): Promise<WorkspaceMigration> {
|
|
88
|
+
const names = await listProfiles(workspaceRoot);
|
|
89
|
+
const legacy: { profile: string; document: ConfigDocument; targets: Record<string, LegacyTarget> }[] =
|
|
90
|
+
[];
|
|
91
|
+
|
|
92
|
+
for (const profile of names) {
|
|
93
|
+
const document = await ConfigDocument.open(workspaceRoot, profile);
|
|
94
|
+
const raw = document.toJSON();
|
|
95
|
+
if (!isLegacyProfile(raw)) continue;
|
|
96
|
+
|
|
97
|
+
const parsed = legacyConfigSchema.safeParse(raw);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
throw new ConfigError(
|
|
100
|
+
`${document.path} is contract 1 but its targets: block could not be read, so it cannot ` +
|
|
101
|
+
`be migrated:\n` +
|
|
102
|
+
parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n'),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
legacy.push({ profile, document, targets: parsed.data.targets });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (legacy.length === 0) {
|
|
110
|
+
return {
|
|
111
|
+
workspaceRoot,
|
|
112
|
+
profiles: [],
|
|
113
|
+
targets: [],
|
|
114
|
+
changes: [],
|
|
115
|
+
alreadyCurrent: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const registry = await hoist(legacy, workspaceRoot);
|
|
120
|
+
|
|
121
|
+
const changes: string[] = [];
|
|
122
|
+
for (const [name, entry] of Object.entries(registry)) {
|
|
123
|
+
changes.push(
|
|
124
|
+
entry.workspace !== undefined
|
|
125
|
+
? `targets.${name}: pointer to ${entry.workspace}`
|
|
126
|
+
: `targets.${name}: declared in ${WORKSPACE_FILE}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
for (const { profile } of legacy) changes.push(`profiles/${profile}.yaml: targets: removed, contract: 2`);
|
|
130
|
+
|
|
131
|
+
if (!options.apply) {
|
|
132
|
+
return {
|
|
133
|
+
workspaceRoot,
|
|
134
|
+
profiles: legacy.map((one) => one.profile),
|
|
135
|
+
targets: describe(registry),
|
|
136
|
+
changes,
|
|
137
|
+
alreadyCurrent: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The registry first. A profile stripped of its targets while the workspace
|
|
142
|
+
// file has not learnt them yet is a profile nothing can open — and that is the
|
|
143
|
+
// window a crash would leave behind. This way round, the worst interruption
|
|
144
|
+
// leaves a workspace that declares its targets *and* profiles that still
|
|
145
|
+
// declare them too, which the next run reconciles by doing the same thing
|
|
146
|
+
// again.
|
|
147
|
+
await writeRegistry(workspaceRoot, registry);
|
|
148
|
+
|
|
149
|
+
for (const { document } of legacy) {
|
|
150
|
+
document.removeIn(['targets']);
|
|
151
|
+
document.setIn(['contract'], SUPPORTED_CONTRACT);
|
|
152
|
+
// `instance.default_target` went with the block it selected from. It has
|
|
153
|
+
// been inert since ADR-037 and there is now nothing for it to name.
|
|
154
|
+
document.removeIn(['instance', 'default_target']);
|
|
155
|
+
// Shape-only: a profile may carry an unrelated problem the full loader
|
|
156
|
+
// refuses — a connection row still spelling a renamed provider is the one
|
|
157
|
+
// that happens — and blocking the structural fix on it would strand the file
|
|
158
|
+
// at a contract nothing reads. `doctor --fix` names and repairs that one.
|
|
159
|
+
await document.save({ shapeOnly: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
workspaceRoot,
|
|
164
|
+
profiles: legacy.map((one) => one.profile),
|
|
165
|
+
targets: describe(registry),
|
|
166
|
+
changes,
|
|
167
|
+
alreadyCurrent: false,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
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
|
+
/**
|
|
289
|
+
* Write the registry into the workspace file, creating it when absent.
|
|
290
|
+
*
|
|
291
|
+
* The whole object is assembled in plain JS and set once. Setting `targets` and
|
|
292
|
+
* then reaching back into it with `setIn(['targets', name, 'primary'])` does not
|
|
293
|
+
* work — the node written from a plain object is not one the document API will
|
|
294
|
+
* traverse — and the failure is a runtime "Expected YAML collection at targets"
|
|
295
|
+
* rather than anything a type would have caught.
|
|
296
|
+
*/
|
|
297
|
+
async function writeRegistry(
|
|
298
|
+
workspaceRoot: string,
|
|
299
|
+
registry: Record<string, WorkspaceTarget>,
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const files = workspaceFiles(workspaceRoot);
|
|
302
|
+
const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
|
|
303
|
+
const document = parseDocument(text);
|
|
304
|
+
const current = (document.toJSON() ?? {}) as {
|
|
305
|
+
targets?: Record<string, WorkspaceTarget>;
|
|
306
|
+
deployments?: {
|
|
307
|
+
target?: string;
|
|
308
|
+
workspace?: string;
|
|
309
|
+
primary?: string;
|
|
310
|
+
last_deploy?: string;
|
|
311
|
+
}[];
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Anything already in the file wins over what was hoisted: a workspace part
|
|
315
|
+
// way through this has entries that are already right, and re-deriving them
|
|
316
|
+
// from a profile that still carries a stale block would undo a correction.
|
|
317
|
+
const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.targets ?? {}) };
|
|
318
|
+
|
|
319
|
+
// ADR-044's index, folded into the entries it described. `primary` and
|
|
320
|
+
// `last_deploy` were kept beside the declaration precisely because the
|
|
321
|
+
// declaration could be lost; they belong on it now that it cannot be.
|
|
322
|
+
for (const record of current.deployments ?? []) {
|
|
323
|
+
if (!record.target) continue;
|
|
324
|
+
const entry = merged[record.target];
|
|
325
|
+
if (!entry) continue;
|
|
326
|
+
merged[record.target] = {
|
|
327
|
+
...entry,
|
|
328
|
+
...(record.primary ? { primary: record.primary } : {}),
|
|
329
|
+
...(record.last_deploy ? { last_deploy: record.last_deploy } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
document.setIn(['contract'], SUPPORTED_CONTRACT);
|
|
334
|
+
document.setIn(
|
|
335
|
+
['targets'],
|
|
336
|
+
Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))),
|
|
337
|
+
);
|
|
338
|
+
document.deleteIn(['deployments']);
|
|
339
|
+
document.deleteIn(['default_target']);
|
|
340
|
+
|
|
341
|
+
await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function describe(
|
|
345
|
+
registry: Record<string, WorkspaceTarget>,
|
|
346
|
+
): { name: string; kind: 'declared' | 'pointer'; where?: string }[] {
|
|
347
|
+
return Object.entries(registry).map(([name, entry]) =>
|
|
348
|
+
entry.workspace !== undefined
|
|
349
|
+
? { name, kind: 'pointer' as const, where: entry.workspace }
|
|
350
|
+
: { name, kind: 'declared' as const },
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
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
|
+
/**
|
|
361
|
+
* Refuse a contract-1 workspace with the command that fixes it.
|
|
362
|
+
*
|
|
363
|
+
* `readRegistry` answers `{}` for one, because the `targets:` block it reads did
|
|
364
|
+
* not exist until contract 2 — so without this every command refuses with "this
|
|
365
|
+
* workspace declares no targets", which is a true sentence pointing in exactly
|
|
366
|
+
* the wrong direction.
|
|
367
|
+
*
|
|
368
|
+
* `update` is named rather than `doctor --fix`, and that is not arbitrary:
|
|
369
|
+
* `doctor` needs a `--target`, and on an unmigrated workspace there is no target
|
|
370
|
+
* to give it. `update` takes neither flag and migrates the local workspace as
|
|
371
|
+
* part of what it already means (ADR-052).
|
|
372
|
+
*/
|
|
373
|
+
export async function refuseIfUnmigrated(root: string): Promise<void> {
|
|
374
|
+
if (!(await needsMigration(root))) return;
|
|
375
|
+
|
|
376
|
+
throw new ConfigError(
|
|
377
|
+
`${root} is a contract 1 workspace, and this version does not read one.\n\n` +
|
|
378
|
+
' Under contract 1 each profile declared its own targets. They are declared\n' +
|
|
379
|
+
' once by the workspace now, which is what stopped a deploy leaving two\n' +
|
|
380
|
+
' copies of every profile that could drift apart (ADR-052).\n\n' +
|
|
381
|
+
' Migrate it: lanes link update\n' +
|
|
382
|
+
' A deployed target is migrated by the deploy that ships the image reading it:\n' +
|
|
383
|
+
' lanes link deploy --target <name>',
|
|
384
|
+
);
|
|
385
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
WORKSPACE_FILE,
|
|
4
|
+
declaredTarget,
|
|
5
|
+
readRegistry,
|
|
6
|
+
recordTarget,
|
|
7
|
+
type Config,
|
|
8
|
+
type DeployConfig,
|
|
9
|
+
type TargetConfig,
|
|
10
|
+
} from '#profile';
|
|
2
11
|
import { VAULT_KEY_ENV, VAULT_KEY_REF } from '#secrets';
|
|
3
12
|
import { ok, print, style } from '#cli/output.ts';
|
|
4
13
|
import { isInteractive } from '#cli/prompt.ts';
|
|
@@ -75,7 +84,11 @@ export async function resolveTarget(input: {
|
|
|
75
84
|
flags: TargetBootstrap;
|
|
76
85
|
}): Promise<TargetConfig> {
|
|
77
86
|
const { config, flags, target } = input;
|
|
78
|
-
|
|
87
|
+
// From the workspace registry, not the profile. A profile declares no target
|
|
88
|
+
// (ADR-052), so the question "what does this workspace already know about
|
|
89
|
+
// `cloud`" is asked of the one file that answers it.
|
|
90
|
+
const entry = (await readRegistry(input.workspaceRoot))[target];
|
|
91
|
+
const declared = entry ? declaredTarget(entry) : undefined;
|
|
79
92
|
const access = parseAccess(flags.access);
|
|
80
93
|
|
|
81
94
|
const overrides = {
|
|
@@ -102,28 +115,35 @@ export async function resolveTarget(input: {
|
|
|
102
115
|
adapters: declared === undefined,
|
|
103
116
|
});
|
|
104
117
|
|
|
105
|
-
|
|
118
|
+
// The target goes to the workspace registry; the authorization block goes to
|
|
119
|
+
// the profile. Two files, because they are two different kinds of fact: where
|
|
120
|
+
// this target's bytes live is true of every profile here, and whether *this*
|
|
121
|
+
// profile issues its own tokens is true of one.
|
|
122
|
+
// `withoutUndefined` and its deep sibling type their result `Partial<T>`,
|
|
123
|
+
// which is stricter than what they do: they drop keys whose value is
|
|
124
|
+
// `undefined`, and a key that was required was never undefined. The casts say
|
|
125
|
+
// that, rather than the shape being genuinely unknown here.
|
|
126
|
+
const entryToWrite = declared
|
|
127
|
+
? { ...declared, deploy: withoutUndefined(surveyed.target.deploy!) as DeployConfig }
|
|
128
|
+
: (deepWithoutUndefined(surveyed.target) as unknown as TargetConfig);
|
|
106
129
|
|
|
107
|
-
|
|
108
|
-
document.setIn(['targets', target, 'deploy'], withoutUndefined(surveyed.target.deploy!));
|
|
109
|
-
} else {
|
|
110
|
-
document.setIn(['targets', target], deepWithoutUndefined(surveyed.target));
|
|
111
|
-
}
|
|
130
|
+
await recordTarget(input.workspaceRoot, target, entryToWrite);
|
|
112
131
|
|
|
113
132
|
// `auth.authorization` is not part of the target and is written all the same:
|
|
114
133
|
// the question that decides it is "will a remote client reach this", which
|
|
115
134
|
// only a deploy is in a position to ask. See `SurveyResult`.
|
|
116
135
|
if (surveyed.authorization) {
|
|
136
|
+
const document = await ConfigDocument.open(input.workspaceRoot, input.profile);
|
|
117
137
|
document.setIn(['auth', 'authorization'], surveyed.authorization);
|
|
138
|
+
await document.save();
|
|
118
139
|
}
|
|
119
|
-
await document.save();
|
|
120
140
|
|
|
121
141
|
print('');
|
|
122
142
|
print(
|
|
123
143
|
ok(
|
|
124
144
|
declared
|
|
125
|
-
? `written to targets.${target}.deploy in ${
|
|
126
|
-
: `written to targets.${target} in ${
|
|
145
|
+
? `written to targets.${target}.deploy in ${WORKSPACE_FILE}`
|
|
146
|
+
: `written to targets.${target} in ${WORKSPACE_FILE}`,
|
|
127
147
|
),
|
|
128
148
|
);
|
|
129
149
|
if (surveyed.authorization) {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
recordTarget,
|
|
4
|
+
resolveWorkspaceRoot,
|
|
5
|
+
type DeployConfig,
|
|
6
|
+
} from '#profile';
|
|
2
7
|
import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
|
|
3
8
|
import { staleNudge } from '#cli/release.ts';
|
|
4
9
|
import { confirm, isInteractive } from '#cli/prompt.ts';
|
|
@@ -8,8 +13,8 @@ import { printSteps, runSteps } from './steps.ts';
|
|
|
8
13
|
import { driverFor } from './drivers.ts';
|
|
9
14
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
10
15
|
import { repairOwnerLayer } from '#cli/config-repair.ts';
|
|
16
|
+
import { migrateWorkspace } from '#cli/workspace-migrate.ts';
|
|
11
17
|
import { deployedWorkspace, uploadWorkspace } from './upload.ts';
|
|
12
|
-
import { unservableProfiles, unservableRefusal } from './servable.ts';
|
|
13
18
|
import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
|
|
14
19
|
import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
|
|
15
20
|
|
|
@@ -200,6 +205,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
200
205
|
const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
|
|
201
206
|
const prepared = await prepareSecrets({
|
|
202
207
|
config,
|
|
208
|
+
declared,
|
|
203
209
|
credentials,
|
|
204
210
|
root: resolution.workspaceRoot,
|
|
205
211
|
target,
|
|
@@ -237,25 +243,12 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
237
243
|
// the upload sends would leave a served profile without the surface — this
|
|
238
244
|
// bug again, one profile over.
|
|
239
245
|
if (workspace) {
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// that
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// scope the upload does, and that scope is not settled until `workspace`
|
|
247
|
-
// says there is a bucket to send to at all.
|
|
248
|
-
const unservable = await unservableProfiles({
|
|
249
|
-
workspaceRoot: resolution.workspaceRoot,
|
|
250
|
-
profiles: serving,
|
|
251
|
-
target,
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
if (unservable.length > 0) {
|
|
255
|
-
heading('Cannot be served');
|
|
256
|
-
throw new ConfigError(unservableRefusal(unservable, target));
|
|
257
|
-
}
|
|
258
|
-
|
|
246
|
+
// The pre-flight that used to sit here is gone with contract 1. It refused
|
|
247
|
+
// a deploy carrying a profile that did not declare the target, because the
|
|
248
|
+
// endpoint opens every profile in the bucket against one target and one
|
|
249
|
+
// that could not run on it took the whole revision down. A profile declares
|
|
250
|
+
// no target now (ADR-052) — it lives in one — so there is no profile in this
|
|
251
|
+
// workspace that this target cannot open, and nothing left to check.
|
|
259
252
|
await repairOwnerLayer(resolution.workspaceRoot, serving);
|
|
260
253
|
|
|
261
254
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
@@ -263,14 +256,48 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
263
256
|
// workspace it was told to read is not there yet.
|
|
264
257
|
await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
|
|
265
258
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
259
|
+
// **The bucket's own migration, here and nowhere else.**
|
|
260
|
+
//
|
|
261
|
+
// Contract 1 is not read by this binary at all (ADR-052), so the moment a
|
|
262
|
+
// bucket becomes contract 2 the revision in front of it stops being able to
|
|
263
|
+
// read its own config. Doing it here puts that window between an upload and
|
|
264
|
+
// a rollout that are seconds apart, rather than leaving it open until
|
|
265
|
+
// somebody happens to redeploy.
|
|
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
|
|
269
|
+
// listing and no writes.
|
|
270
|
+
const migrated = await migrateWorkspace(workspace);
|
|
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
|
+
}
|
|
276
|
+
|
|
277
|
+
// **The target hands itself over to the workspace it now lives in.**
|
|
278
|
+
//
|
|
279
|
+
// Two writes, and the order matters. The declaration goes into the bucket's
|
|
280
|
+
// own `lanes-link.yaml` first, because that is the file the revision coming
|
|
281
|
+
// up will read to learn where its credentials and bytes are — and a
|
|
282
|
+
// revision that boots before it lands has nothing to open.
|
|
283
|
+
//
|
|
284
|
+
// The local entry then becomes a *pointer*. That is the whole of ADR-052 in
|
|
285
|
+
// two lines: after this, exactly one file declares this target, and this
|
|
286
|
+
// machine holds a reference to it rather than a copy. ADR-044's index
|
|
287
|
+
// existed because the profile's own block was the only record and could be
|
|
288
|
+
// lost in one edit; there is no second copy left to lose.
|
|
289
|
+
const stamped = new Date().toISOString();
|
|
290
|
+
|
|
291
|
+
await recordTarget(workspace, target, {
|
|
292
|
+
...declared,
|
|
293
|
+
primary: resolution.profile,
|
|
294
|
+
last_deploy: stamped,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await recordTarget(resolution.workspaceRoot, target, {
|
|
271
298
|
workspace,
|
|
272
299
|
primary: resolution.profile,
|
|
273
|
-
last_deploy:
|
|
300
|
+
last_deploy: stamped,
|
|
274
301
|
});
|
|
275
302
|
}
|
|
276
303
|
|
|
@@ -57,8 +57,11 @@ export async function openKnowledge(
|
|
|
57
57
|
/** Injected for tests. The repository is the only thing these stores reach. */
|
|
58
58
|
call?: FetchLike,
|
|
59
59
|
): Promise<KnowledgeStores | undefined> {
|
|
60
|
-
const {
|
|
61
|
-
|
|
60
|
+
const { config, target } = input;
|
|
61
|
+
// On the profile since contract 2: it says where *this profile's* memory and
|
|
62
|
+
// skills live, and a profile lives in exactly one target (ADR-052), so the
|
|
63
|
+
// per-target spelling it replaced could no longer say anything extra.
|
|
64
|
+
const knowledge = config.knowledge;
|
|
62
65
|
if (!knowledge) return undefined;
|
|
63
66
|
|
|
64
67
|
const token = await requireSecret(
|
|
@@ -22,6 +22,8 @@ import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts'
|
|
|
22
22
|
|
|
23
23
|
export interface PrepareInput {
|
|
24
24
|
readonly config: Config;
|
|
25
|
+
/** The target's adapter set, from the workspace that declares it (ADR-052). */
|
|
26
|
+
readonly declared: TargetConfig;
|
|
25
27
|
readonly credentials: SecretStore;
|
|
26
28
|
readonly root: string;
|
|
27
29
|
readonly target: string;
|
|
@@ -178,7 +180,7 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
|
|
|
178
180
|
const warnings: string[] = [];
|
|
179
181
|
|
|
180
182
|
await seedProfileToken({ config, credentials, readOnly, blocking });
|
|
181
|
-
await seedVaultKey({ declared:
|
|
183
|
+
await seedVaultKey({ declared: input.declared, credentials, readOnly });
|
|
182
184
|
|
|
183
185
|
// Connection credentials are written by `connect`, against a real account, in
|
|
184
186
|
// a browser. Nothing here can produce one, and a deploy that stopped for one
|