@lanes-sh/link 0.5.4 → 0.6.1
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/migrate-plan.ts +160 -0
- 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 +263 -0
- package/src/deployments/bootstrap.ts +31 -11
- package/src/deployments/deploy.ts +103 -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,263 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
2
|
+
import {
|
|
3
|
+
ConfigError,
|
|
4
|
+
SUPPORTED_CONTRACT,
|
|
5
|
+
WORKSPACE_FILE,
|
|
6
|
+
layout,
|
|
7
|
+
listProfiles,
|
|
8
|
+
readWorkspaceFile,
|
|
9
|
+
isRemoteWorkspace,
|
|
10
|
+
workspaceFiles,
|
|
11
|
+
isLegacyProfile,
|
|
12
|
+
legacyConfigSchema,
|
|
13
|
+
writeWorkspaceFile,
|
|
14
|
+
type LegacyTarget,
|
|
15
|
+
type WorkspaceTarget,
|
|
16
|
+
} from '#profile';
|
|
17
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
18
|
+
import { hoist, summarise } from './migrate-plan.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Contract 1 → 2: the target moves out of the profile and into the workspace.
|
|
22
|
+
*
|
|
23
|
+
* Under contract 1 every profile carried a `targets:` block naming the adapter
|
|
24
|
+
* sets it could be opened against. That is what made a deploy leave two copies
|
|
25
|
+
* of each profile — one in `~/.lanes-link`, one in the bucket the endpoint reads
|
|
26
|
+
* — and gave them nothing to keep them honest. It failed the way it was always
|
|
27
|
+
* going to: a local file was rewritten, lost its cloud target and eight
|
|
28
|
+
* connections, and `status --target cloud` reported seven where the endpoint was
|
|
29
|
+
* serving fifteen. `sync targets` and ADR-044's deployment index were both
|
|
30
|
+
* written in response to earlier rounds of the same thing.
|
|
31
|
+
*
|
|
32
|
+
* ADR-052 removes the second copy. A workspace *is* a target: it declares its
|
|
33
|
+
* adapters once, in its own `lanes-link.yaml`, and holds the profiles that live
|
|
34
|
+
* in it. This is the one-way trip that gets an existing workspace there.
|
|
35
|
+
*
|
|
36
|
+
* **What it does, per workspace:** hoists each profile's target blocks into the
|
|
37
|
+
* workspace registry, strips `targets:` from the profile, and sets `contract: 2`.
|
|
38
|
+
* A block whose storage names a bucket becomes a *pointer* locally — the bucket
|
|
39
|
+
* declares it — and the adapters travel to that workspace when `deploy` next
|
|
40
|
+
* runs, which is the only command that can put them there safely.
|
|
41
|
+
*
|
|
42
|
+
* **Where it runs** follows `commands/operate/migrate.ts`'s reasoning: an
|
|
43
|
+
* operator should not have to know a migration command exists before their
|
|
44
|
+
* config breaks. So `update` runs it on the local workspace automatically,
|
|
45
|
+
* `doctor --fix` runs it on demand, and `deploy` runs it on the target workspace
|
|
46
|
+
* as its first step — which is what keeps a bucket and the image reading it in
|
|
47
|
+
* step, given contract 1 is not read at all.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
export interface WorkspaceMigration {
|
|
51
|
+
readonly workspaceRoot: string;
|
|
52
|
+
/** Profiles rewritten to contract 2. */
|
|
53
|
+
readonly profiles: readonly string[];
|
|
54
|
+
/** Targets written into the registry, and how each was recorded. */
|
|
55
|
+
readonly targets: readonly { name: string; kind: 'declared' | 'pointer'; where?: string }[];
|
|
56
|
+
/** Human-readable lines, in the order they happened. */
|
|
57
|
+
readonly changes: readonly string[];
|
|
58
|
+
/** Nothing to do: every profile was already contract 2. */
|
|
59
|
+
readonly alreadyCurrent: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether this workspace still holds anything at contract 1. */
|
|
63
|
+
export async function needsMigration(workspaceRoot: string): Promise<boolean> {
|
|
64
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
65
|
+
const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), `profiles/${profile}.yaml`);
|
|
66
|
+
if (text === null) continue;
|
|
67
|
+
try {
|
|
68
|
+
if (isLegacyProfile(parseDocument(text).toJSON())) return true;
|
|
69
|
+
} catch {
|
|
70
|
+
// A file that will not parse is not this function's problem to report.
|
|
71
|
+
// `check` gives it a better sentence than "needs migrating" would.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Migrate one workspace. Safe to run on an already-migrated one.
|
|
79
|
+
*
|
|
80
|
+
* **Everything that can fail happens before the first write.** A refusal leaves
|
|
81
|
+
* the workspace exactly as it was rather than half-migrated, which matters more
|
|
82
|
+
* here than usual: the thing being rewritten is the only remaining description
|
|
83
|
+
* of where somebody's accounts live.
|
|
84
|
+
*/
|
|
85
|
+
export async function migrateWorkspace(
|
|
86
|
+
workspaceRoot: string,
|
|
87
|
+
options: { apply: boolean } = { apply: true },
|
|
88
|
+
): Promise<WorkspaceMigration> {
|
|
89
|
+
const names = await listProfiles(workspaceRoot);
|
|
90
|
+
const legacy: { profile: string; document: ConfigDocument; targets: Record<string, LegacyTarget> }[] =
|
|
91
|
+
[];
|
|
92
|
+
|
|
93
|
+
for (const profile of names) {
|
|
94
|
+
const document = await ConfigDocument.open(workspaceRoot, profile);
|
|
95
|
+
const raw = document.toJSON();
|
|
96
|
+
if (!isLegacyProfile(raw)) continue;
|
|
97
|
+
|
|
98
|
+
const parsed = legacyConfigSchema.safeParse(raw);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
throw new ConfigError(
|
|
101
|
+
`${document.path} is contract 1 but its targets: block could not be read, so it cannot ` +
|
|
102
|
+
`be migrated:\n` +
|
|
103
|
+
parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n'),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
legacy.push({ profile, document, targets: parsed.data.targets });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (legacy.length === 0) {
|
|
111
|
+
return {
|
|
112
|
+
workspaceRoot,
|
|
113
|
+
profiles: [],
|
|
114
|
+
targets: [],
|
|
115
|
+
changes: [],
|
|
116
|
+
alreadyCurrent: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const registry = await hoist(legacy, workspaceRoot);
|
|
121
|
+
|
|
122
|
+
const changes: string[] = [];
|
|
123
|
+
for (const [name, entry] of Object.entries(registry)) {
|
|
124
|
+
changes.push(
|
|
125
|
+
entry.workspace !== undefined
|
|
126
|
+
? `targets.${name}: pointer to ${entry.workspace}`
|
|
127
|
+
: `targets.${name}: declared in ${WORKSPACE_FILE}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
for (const { profile } of legacy) changes.push(`profiles/${profile}.yaml: targets: removed, contract: 2`);
|
|
131
|
+
|
|
132
|
+
if (!options.apply) {
|
|
133
|
+
return {
|
|
134
|
+
workspaceRoot,
|
|
135
|
+
profiles: legacy.map((one) => one.profile),
|
|
136
|
+
targets: describe(registry),
|
|
137
|
+
changes,
|
|
138
|
+
alreadyCurrent: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The registry first. A profile stripped of its targets while the workspace
|
|
143
|
+
// file has not learnt them yet is a profile nothing can open — and that is the
|
|
144
|
+
// window a crash would leave behind. This way round, the worst interruption
|
|
145
|
+
// leaves a workspace that declares its targets *and* profiles that still
|
|
146
|
+
// declare them too, which the next run reconciles by doing the same thing
|
|
147
|
+
// again.
|
|
148
|
+
await writeRegistry(workspaceRoot, registry);
|
|
149
|
+
|
|
150
|
+
for (const { document } of legacy) {
|
|
151
|
+
document.removeIn(['targets']);
|
|
152
|
+
document.setIn(['contract'], SUPPORTED_CONTRACT);
|
|
153
|
+
// `instance.default_target` went with the block it selected from. It has
|
|
154
|
+
// been inert since ADR-037 and there is now nothing for it to name.
|
|
155
|
+
document.removeIn(['instance', 'default_target']);
|
|
156
|
+
// Shape-only: a profile may carry an unrelated problem the full loader
|
|
157
|
+
// refuses — a connection row still spelling a renamed provider is the one
|
|
158
|
+
// that happens — and blocking the structural fix on it would strand the file
|
|
159
|
+
// at a contract nothing reads. `doctor --fix` names and repairs that one.
|
|
160
|
+
await document.save({ shapeOnly: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
workspaceRoot,
|
|
165
|
+
profiles: legacy.map((one) => one.profile),
|
|
166
|
+
targets: describe(registry),
|
|
167
|
+
changes,
|
|
168
|
+
alreadyCurrent: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Write the registry into the workspace file, creating it when absent.
|
|
174
|
+
*
|
|
175
|
+
* The whole object is assembled in plain JS and set once. Setting `targets` and
|
|
176
|
+
* then reaching back into it with `setIn(['targets', name, 'primary'])` does not
|
|
177
|
+
* work — the node written from a plain object is not one the document API will
|
|
178
|
+
* traverse — and the failure is a runtime "Expected YAML collection at targets"
|
|
179
|
+
* rather than anything a type would have caught.
|
|
180
|
+
*/
|
|
181
|
+
async function writeRegistry(
|
|
182
|
+
workspaceRoot: string,
|
|
183
|
+
registry: Record<string, WorkspaceTarget>,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const files = workspaceFiles(workspaceRoot);
|
|
186
|
+
const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
|
|
187
|
+
const document = parseDocument(text);
|
|
188
|
+
const current = (document.toJSON() ?? {}) as {
|
|
189
|
+
targets?: Record<string, WorkspaceTarget>;
|
|
190
|
+
deployments?: {
|
|
191
|
+
target?: string;
|
|
192
|
+
workspace?: string;
|
|
193
|
+
primary?: string;
|
|
194
|
+
last_deploy?: string;
|
|
195
|
+
}[];
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Anything already in the file wins over what was hoisted: a workspace part
|
|
199
|
+
// way through this has entries that are already right, and re-deriving them
|
|
200
|
+
// from a profile that still carries a stale block would undo a correction.
|
|
201
|
+
const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.targets ?? {}) };
|
|
202
|
+
|
|
203
|
+
// ADR-044's index, folded into the entries it described. `primary` and
|
|
204
|
+
// `last_deploy` were kept beside the declaration precisely because the
|
|
205
|
+
// declaration could be lost; they belong on it now that it cannot be.
|
|
206
|
+
for (const record of current.deployments ?? []) {
|
|
207
|
+
if (!record.target) continue;
|
|
208
|
+
const entry = merged[record.target];
|
|
209
|
+
if (!entry) continue;
|
|
210
|
+
merged[record.target] = {
|
|
211
|
+
...entry,
|
|
212
|
+
...(record.primary ? { primary: record.primary } : {}),
|
|
213
|
+
...(record.last_deploy ? { last_deploy: record.last_deploy } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
document.setIn(['contract'], SUPPORTED_CONTRACT);
|
|
218
|
+
document.setIn(
|
|
219
|
+
['targets'],
|
|
220
|
+
Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))),
|
|
221
|
+
);
|
|
222
|
+
document.deleteIn(['deployments']);
|
|
223
|
+
document.deleteIn(['default_target']);
|
|
224
|
+
|
|
225
|
+
await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function describe(
|
|
229
|
+
registry: Record<string, WorkspaceTarget>,
|
|
230
|
+
): { name: string; kind: 'declared' | 'pointer'; where?: string }[] {
|
|
231
|
+
return Object.entries(registry).map(([name, entry]) =>
|
|
232
|
+
entry.workspace !== undefined
|
|
233
|
+
? { name, kind: 'pointer' as const, where: entry.workspace }
|
|
234
|
+
: { name, kind: 'declared' as const },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Refuse a contract-1 workspace with the command that fixes it.
|
|
240
|
+
*
|
|
241
|
+
* `readRegistry` answers `{}` for one, because the `targets:` block it reads did
|
|
242
|
+
* not exist until contract 2 — so without this every command refuses with "this
|
|
243
|
+
* workspace declares no targets", which is a true sentence pointing in exactly
|
|
244
|
+
* the wrong direction.
|
|
245
|
+
*
|
|
246
|
+
* `update` is named rather than `doctor --fix`, and that is not arbitrary:
|
|
247
|
+
* `doctor` needs a `--target`, and on an unmigrated workspace there is no target
|
|
248
|
+
* to give it. `update` takes neither flag and migrates the local workspace as
|
|
249
|
+
* part of what it already means (ADR-052).
|
|
250
|
+
*/
|
|
251
|
+
export async function refuseIfUnmigrated(root: string): Promise<void> {
|
|
252
|
+
if (!(await needsMigration(root))) return;
|
|
253
|
+
|
|
254
|
+
throw new ConfigError(
|
|
255
|
+
`${root} is a contract 1 workspace, and this version does not read one.\n\n` +
|
|
256
|
+
' Under contract 1 each profile declared its own targets. They are declared\n' +
|
|
257
|
+
' once by the workspace now, which is what stopped a deploy leaving two\n' +
|
|
258
|
+
' copies of every profile that could drift apart (ADR-052).\n\n' +
|
|
259
|
+
' Migrate it: lanes link update\n' +
|
|
260
|
+
' A deployed target is migrated by the deploy that ships the image reading it:\n' +
|
|
261
|
+
' lanes link deploy --target <name>',
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -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,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
recordTarget,
|
|
4
|
+
resolveTargetWorkspace,
|
|
5
|
+
resolveWorkspaceRoot,
|
|
6
|
+
type DeployConfig,
|
|
7
|
+
} from '#profile';
|
|
2
8
|
import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
|
|
3
9
|
import { staleNudge } from '#cli/release.ts';
|
|
4
10
|
import { confirm, isInteractive } from '#cli/prompt.ts';
|
|
@@ -8,8 +14,8 @@ import { printSteps, runSteps } from './steps.ts';
|
|
|
8
14
|
import { driverFor } from './drivers.ts';
|
|
9
15
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
10
16
|
import { repairOwnerLayer } from '#cli/config-repair.ts';
|
|
17
|
+
import { migrateWorkspace } from '#cli/workspace-migrate.ts';
|
|
11
18
|
import { deployedWorkspace, uploadWorkspace } from './upload.ts';
|
|
12
|
-
import { unservableProfiles, unservableRefusal } from './servable.ts';
|
|
13
19
|
import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
|
|
14
20
|
import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
|
|
15
21
|
|
|
@@ -56,6 +62,23 @@ export interface DeployFlags extends GlobalFlags {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
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
|
+
|
|
59
82
|
// The one command allowed to name a target that does not exist yet: creating
|
|
60
83
|
// it is what a first deploy is for.
|
|
61
84
|
//
|
|
@@ -200,6 +223,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
200
223
|
const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
|
|
201
224
|
const prepared = await prepareSecrets({
|
|
202
225
|
config,
|
|
226
|
+
declared,
|
|
203
227
|
credentials,
|
|
204
228
|
root: resolution.workspaceRoot,
|
|
205
229
|
target,
|
|
@@ -237,25 +261,12 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
237
261
|
// the upload sends would leave a served profile without the surface — this
|
|
238
262
|
// bug again, one profile over.
|
|
239
263
|
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
|
-
|
|
264
|
+
// The pre-flight that used to sit here is gone with contract 1. It refused
|
|
265
|
+
// a deploy carrying a profile that did not declare the target, because the
|
|
266
|
+
// endpoint opens every profile in the bucket against one target and one
|
|
267
|
+
// that could not run on it took the whole revision down. A profile declares
|
|
268
|
+
// no target now (ADR-052) — it lives in one — so there is no profile in this
|
|
269
|
+
// workspace that this target cannot open, and nothing left to check.
|
|
259
270
|
await repairOwnerLayer(resolution.workspaceRoot, serving);
|
|
260
271
|
|
|
261
272
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
@@ -263,14 +274,40 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
263
274
|
// workspace it was told to read is not there yet.
|
|
264
275
|
await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
|
|
265
276
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
277
|
+
// **The bucket's own migration, here and nowhere else.**
|
|
278
|
+
//
|
|
279
|
+
// A second pass, and it is not redundant. The one at the top of the command
|
|
280
|
+
// migrated whatever the bucket already held; this catches what the upload
|
|
281
|
+
// just put there — profiles from a workspace that is itself at contract 2
|
|
282
|
+
// arrive migrated, but a first deploy of a *newly created* bucket writes
|
|
283
|
+
// them here for the first time. Idempotent, so the ordinary case is one
|
|
284
|
+
// listing and no writes.
|
|
285
|
+
await migrateWorkspace(workspace, { apply: true });
|
|
286
|
+
|
|
287
|
+
// **The target hands itself over to the workspace it now lives in.**
|
|
288
|
+
//
|
|
289
|
+
// Two writes, and the order matters. The declaration goes into the bucket's
|
|
290
|
+
// own `lanes-link.yaml` first, because that is the file the revision coming
|
|
291
|
+
// up will read to learn where its credentials and bytes are — and a
|
|
292
|
+
// revision that boots before it lands has nothing to open.
|
|
293
|
+
//
|
|
294
|
+
// The local entry then becomes a *pointer*. That is the whole of ADR-052 in
|
|
295
|
+
// two lines: after this, exactly one file declares this target, and this
|
|
296
|
+
// machine holds a reference to it rather than a copy. ADR-044's index
|
|
297
|
+
// existed because the profile's own block was the only record and could be
|
|
298
|
+
// lost in one edit; there is no second copy left to lose.
|
|
299
|
+
const stamped = new Date().toISOString();
|
|
300
|
+
|
|
301
|
+
await recordTarget(workspace, target, {
|
|
302
|
+
...declared,
|
|
303
|
+
primary: resolution.profile,
|
|
304
|
+
last_deploy: stamped,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await recordTarget(resolution.workspaceRoot, target, {
|
|
271
308
|
workspace,
|
|
272
309
|
primary: resolution.profile,
|
|
273
|
-
last_deploy:
|
|
310
|
+
last_deploy: stamped,
|
|
274
311
|
});
|
|
275
312
|
}
|
|
276
313
|
|
|
@@ -303,3 +340,42 @@ function requireTargetFlag(flags: DeployFlags): string {
|
|
|
303
340
|
}
|
|
304
341
|
return flags.target;
|
|
305
342
|
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Bring a target's own workspace to the current contract, before it is opened.
|
|
346
|
+
*
|
|
347
|
+
* Deliberately tolerant of a target this workspace has no pointer for: that is a
|
|
348
|
+
* first deploy, where there is nothing to migrate and `deploy` is about to create
|
|
349
|
+
* the workspace itself.
|
|
350
|
+
*
|
|
351
|
+
* Narrated when it does something. This rewrites every profile in somebody's
|
|
352
|
+
* bucket, and a command that reshapes that silently is one they cannot audit
|
|
353
|
+
* afterwards.
|
|
354
|
+
*/
|
|
355
|
+
async function migrateTargetWorkspace(target: string, apply: boolean): Promise<boolean> {
|
|
356
|
+
const root = resolveWorkspaceRoot();
|
|
357
|
+
|
|
358
|
+
const workspace = await resolveTargetWorkspace(root, target).catch(() => null);
|
|
359
|
+
if (workspace === null || workspace === root) return true;
|
|
360
|
+
|
|
361
|
+
const migrated = await migrateWorkspace(workspace, { apply });
|
|
362
|
+
if (migrated.alreadyCurrent) return true;
|
|
363
|
+
|
|
364
|
+
heading(apply ? 'Migrated' : 'Would migrate');
|
|
365
|
+
print(style.dim(` ${workspace}`));
|
|
366
|
+
for (const change of migrated.changes) print(` ${change}`);
|
|
367
|
+
if (apply) {
|
|
368
|
+
print(style.dim(' The revision this deploy rolls is the first that can read it.'));
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// A dry run stops here rather than pressing on to survey and plan. Everything
|
|
373
|
+
// past this point opens the target, and the target is not readable until the
|
|
374
|
+
// migration above has actually happened — so continuing would report a second,
|
|
375
|
+
// confusing refusal about the thing the first paragraph just offered to fix.
|
|
376
|
+
print(style.dim(' Nothing was written, and nothing else was checked: the rest of this'));
|
|
377
|
+
print(style.dim(' command opens the target, which is not readable until this has run.'));
|
|
378
|
+
print('');
|
|
379
|
+
print(style.dim(` Run it for real: lanes link deploy --target ${target}`));
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
@@ -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
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ConfigError,
|
|
3
|
-
|
|
3
|
+
listProfiles,
|
|
4
4
|
loadWorkspaceProfiles,
|
|
5
|
+
readRegistry,
|
|
5
6
|
type WorkspaceProfiles,
|
|
6
7
|
} from '#profile';
|
|
7
8
|
import { rotatableCredentialRefsFor } from '#registry';
|
|
@@ -17,10 +18,16 @@ import { buildRegistryWithWorkspace } from '#cli/runtime.ts';
|
|
|
17
18
|
* profile did not declare yet — which the survey then offered to create
|
|
18
19
|
* somewhere new. The way to deploy both was to know that already.
|
|
19
20
|
*
|
|
20
|
-
* The set is derived rather than guessed: it is every profile
|
|
21
|
-
*
|
|
22
|
-
* narrows it, and naming one is still how a first deploy works, because a
|
|
23
|
-
*
|
|
21
|
+
* The set is derived rather than guessed: it is every profile *in* the target's
|
|
22
|
+
* workspace, which is exactly the set the endpoint will try to open. `--profile`
|
|
23
|
+
* narrows it, and naming one is still how a first deploy works, because a target
|
|
24
|
+
* that does not exist yet has no workspace to derive from (ADR-043, ADR-052).
|
|
25
|
+
*
|
|
26
|
+
* It used to be "every profile declaring the target", read out of each profile's
|
|
27
|
+
* own file. That is the shape ADR-052 removed: the same question had a different
|
|
28
|
+
* answer per profile, so a rewritten file could drop a profile out of the set
|
|
29
|
+
* silently and the deploy would quietly send fewer profiles than the endpoint
|
|
30
|
+
* was serving.
|
|
24
31
|
*/
|
|
25
32
|
|
|
26
33
|
export interface Serving {
|
|
@@ -42,23 +49,20 @@ export async function servingProfiles(input: {
|
|
|
42
49
|
return { profiles: [...named], primary: named[0]! };
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
const
|
|
46
|
-
const declaring = workspace.loaded
|
|
47
|
-
.filter((entry) => target in entry.config.targets)
|
|
48
|
-
.map((entry) => entry.profile);
|
|
52
|
+
const living = await listProfiles(workspaceRoot);
|
|
49
53
|
|
|
50
|
-
if (
|
|
54
|
+
if (living.length === 0) {
|
|
51
55
|
throw new ConfigError(
|
|
52
|
-
`No profile
|
|
56
|
+
`No profile lives in "${target}", so there is no set to deploy.\n` +
|
|
53
57
|
' A first deploy creates the target, and has to be told which profile\n' +
|
|
54
58
|
' it belongs to:\n' +
|
|
55
59
|
` lanes link deploy --target ${target} --profile <name>\n\n` +
|
|
56
|
-
` If "${target}" was deployed before and the
|
|
60
|
+
` If "${target}" was deployed before and the pointer to it was lost:\n` +
|
|
57
61
|
` lanes link sync targets --target ${target} --discover`,
|
|
58
62
|
);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
return { profiles:
|
|
65
|
+
return { profiles: living, primary: await choosePrimary(workspaceRoot, target, living) };
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/**
|
|
@@ -75,13 +79,13 @@ async function choosePrimary(
|
|
|
75
79
|
target: string,
|
|
76
80
|
declaring: readonly string[],
|
|
77
81
|
): Promise<string> {
|
|
78
|
-
const recorded = (await
|
|
82
|
+
const recorded = (await readRegistry(workspaceRoot))[target]?.primary;
|
|
79
83
|
if (recorded !== undefined && declaring.includes(recorded)) return recorded;
|
|
80
84
|
|
|
81
85
|
if (declaring.length === 1) return declaring[0]!;
|
|
82
86
|
|
|
83
87
|
throw new ConfigError(
|
|
84
|
-
`${declaring.length} profiles
|
|
88
|
+
`${declaring.length} profiles live in "${target}", and nothing records which\n` +
|
|
85
89
|
"of them owns the endpoint's token. One token opens the endpoint and\n" +
|
|
86
90
|
'reaches every profile behind it, so this cannot be picked for you.\n\n' +
|
|
87
91
|
` Name it once and it is remembered:\n` +
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
openTarget,
|
|
2
3
|
DATA_DIR,
|
|
3
4
|
WORKSPACE_FILE,
|
|
4
5
|
layout,
|
|
@@ -150,7 +151,15 @@ export async function publishWorkspace(input: {
|
|
|
150
151
|
readonly target: string;
|
|
151
152
|
readonly profile: string;
|
|
152
153
|
}): Promise<string | null> {
|
|
153
|
-
|
|
154
|
+
// Resolution failures are swallowed rather than thrown. The config edit that
|
|
155
|
+
// called this has already succeeded and is on disk; a target that cannot be
|
|
156
|
+
// resolved — undeclared, or a pointer to a bucket that is not answering — is
|
|
157
|
+
// something for `check` and `status` to report, not a reason to fail an edit
|
|
158
|
+
// that is done. Returning null is "published nowhere", which is exactly what
|
|
159
|
+
// happened.
|
|
160
|
+
const declared = await openTarget(input.workspaceRoot, input.target)
|
|
161
|
+
.then((resolved) => resolved.declared)
|
|
162
|
+
.catch(() => undefined);
|
|
154
163
|
if (!declared) return null;
|
|
155
164
|
|
|
156
165
|
const destination = deployedWorkspace(declared);
|