@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
|
@@ -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);
|
|
@@ -1,76 +1,83 @@
|
|
|
1
1
|
import { parseDocument } from 'yaml';
|
|
2
2
|
import { readWorkspaceFile, workspaceFiles, writeWorkspaceFile } from './files.ts';
|
|
3
|
-
import { workspaceSchema, type
|
|
4
|
-
import { WORKSPACE_FILE
|
|
3
|
+
import { SUPPORTED_CONTRACT, workspaceSchema, type WorkspaceTarget } from './schema.ts';
|
|
4
|
+
import { WORKSPACE_FILE } from './workspace.ts';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Writing the target registry.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* running. Nothing could find it.
|
|
9
|
+
* ADR-044 kept a deployment *index* here and had to insist it was "an index, not
|
|
10
|
+
* configuration", because a target was declared by the profile and resolving
|
|
11
|
+
* from anything else would have been a second source of truth. ADR-052 removed
|
|
12
|
+
* the thing it was second to: the profile declares no target now, so this file
|
|
13
|
+
* writes the only declaration there is.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* Which is why the entry shape matters. A workspace that *is* the target writes
|
|
16
|
+
* its adapters; a machine reaching it writes a pointer. `registry.ts` follows
|
|
17
|
+
* one to the other, and the schema refuses an entry trying to be both.
|
|
18
|
+
*
|
|
19
|
+
* Everything goes through the YAML document API, so the comments in a workspace
|
|
20
|
+
* file an operator has annotated survive being written to.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
/** Every deployment the workspace has recorded. Empty for a workspace with none. */
|
|
24
|
-
export async function readDeployments(workspaceRoot: string): Promise<DeploymentRecord[]> {
|
|
25
|
-
// A workspace file that will not parse is not a reason to fail a recovery:
|
|
26
|
-
// the caller has other ways to find a target, and this is the cheapest.
|
|
27
|
-
try {
|
|
28
|
-
return (await readWorkspace(workspaceRoot))?.deployments ?? [];
|
|
29
|
-
} catch {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** What the workspace knows about one target, if anything. */
|
|
35
|
-
export async function findDeployment(
|
|
36
|
-
workspaceRoot: string,
|
|
37
|
-
target: string,
|
|
38
|
-
): Promise<DeploymentRecord | undefined> {
|
|
39
|
-
return (await readDeployments(workspaceRoot)).find((entry) => entry.target === target);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
23
|
/**
|
|
43
|
-
* Record a
|
|
24
|
+
* Record a target, replacing any earlier entry of the same name.
|
|
44
25
|
*
|
|
45
|
-
* Keyed by
|
|
46
|
-
* definition — a second entry would be a history, and a history is a
|
|
47
|
-
* read wrong.
|
|
48
|
-
* record naming the new one.
|
|
26
|
+
* Keyed by name rather than appended, because a workspace has one answer per
|
|
27
|
+
* target by definition — a second entry would be a history, and a history is a
|
|
28
|
+
* thing to read wrong.
|
|
49
29
|
*
|
|
50
|
-
* Merged
|
|
30
|
+
* Merged over the existing entry so a field this caller does not know about —
|
|
51
31
|
* `primary`, on a redeploy that did not ask — is carried forward rather than
|
|
52
|
-
* dropped.
|
|
53
|
-
*
|
|
54
|
-
* Written through the YAML document API, so the comments in a workspace file an
|
|
55
|
-
* operator has annotated survive being indexed.
|
|
32
|
+
* dropped. That merge is why `deploy` can record a new `last_deploy` without
|
|
33
|
+
* having to re-answer whose token opens the endpoint.
|
|
56
34
|
*/
|
|
57
|
-
export async function
|
|
35
|
+
export async function recordTarget(
|
|
58
36
|
workspaceRoot: string,
|
|
59
|
-
|
|
37
|
+
target: string,
|
|
38
|
+
entry: WorkspaceTarget,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
await editRegistry(workspaceRoot, (targets) => {
|
|
41
|
+
const previous = targets[target];
|
|
42
|
+
// A pointer replacing a declaration is `deploy` handing the target over to
|
|
43
|
+
// the workspace it just wrote, so the adapter keys have to go rather than
|
|
44
|
+
// merge — an entry carrying both is what the schema refuses.
|
|
45
|
+
targets[target] =
|
|
46
|
+
entry.workspace !== undefined ? { ...pick(previous), ...entry } : { ...previous, ...entry };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Forget a target. Used by `profile remove --target` once nothing is left in it. */
|
|
51
|
+
export async function removeTarget(workspaceRoot: string, target: string): Promise<void> {
|
|
52
|
+
await editRegistry(workspaceRoot, (targets) => {
|
|
53
|
+
delete targets[target];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The fields that survive a declaration becoming a pointer: the deploy record. */
|
|
58
|
+
function pick(previous: WorkspaceTarget | undefined): Partial<WorkspaceTarget> {
|
|
59
|
+
if (!previous) return {};
|
|
60
|
+
return {
|
|
61
|
+
...(previous.primary ? { primary: previous.primary } : {}),
|
|
62
|
+
...(previous.last_deploy ? { last_deploy: previous.last_deploy } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function editRegistry(
|
|
67
|
+
workspaceRoot: string,
|
|
68
|
+
edit: (targets: Record<string, WorkspaceTarget>) => void,
|
|
60
69
|
): Promise<void> {
|
|
61
70
|
const files = workspaceFiles(workspaceRoot);
|
|
62
|
-
const text =
|
|
71
|
+
const text =
|
|
72
|
+
(await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
|
|
63
73
|
|
|
64
74
|
const document = parseDocument(text);
|
|
65
|
-
const
|
|
66
|
-
const previous = existing.find((record) => record.target === entry.target);
|
|
75
|
+
const targets = (document.toJSON()?.targets ?? {}) as Record<string, WorkspaceTarget>;
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
...existing.filter((record) => record.target !== entry.target),
|
|
70
|
-
{ ...previous, ...entry },
|
|
71
|
-
].sort((a, b) => a.target.localeCompare(b.target));
|
|
77
|
+
edit(targets);
|
|
72
78
|
|
|
73
|
-
document.
|
|
79
|
+
if (Object.keys(targets).length === 0) document.deleteIn(['targets']);
|
|
80
|
+
else document.setIn(['targets'], sorted(targets));
|
|
74
81
|
|
|
75
82
|
// Validated before it lands, on the rendered tree rather than the input, so
|
|
76
83
|
// what is checked is what would be read back.
|
|
@@ -78,3 +85,7 @@ export async function recordDeployment(
|
|
|
78
85
|
|
|
79
86
|
await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
|
|
80
87
|
}
|
|
88
|
+
|
|
89
|
+
function sorted(targets: Record<string, WorkspaceTarget>): Record<string, WorkspaceTarget> {
|
|
90
|
+
return Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b)));
|
|
91
|
+
}
|
package/src/profile/index.ts
CHANGED
|
@@ -14,17 +14,19 @@
|
|
|
14
14
|
export {
|
|
15
15
|
SUPPORTED_CONTRACT,
|
|
16
16
|
configSchema,
|
|
17
|
-
|
|
17
|
+
declaredTarget,
|
|
18
|
+
isPointer,
|
|
18
19
|
workspaceSchema,
|
|
20
|
+
workspaceTargetSchema,
|
|
19
21
|
type AuthorizationConfig,
|
|
20
22
|
type Config,
|
|
21
23
|
type ConnectionConfig,
|
|
22
24
|
type DeployConfig,
|
|
23
|
-
type DeploymentRecord,
|
|
24
25
|
type IdentityEntry,
|
|
25
26
|
type PolicyRuleConfig,
|
|
26
27
|
type TargetConfig,
|
|
27
28
|
type WorkspaceConfig,
|
|
29
|
+
type WorkspaceTarget,
|
|
28
30
|
} from './schema.ts';
|
|
29
31
|
|
|
30
32
|
export {
|
|
@@ -66,19 +68,33 @@ export {
|
|
|
66
68
|
readWorkspace,
|
|
67
69
|
resolveSelection,
|
|
68
70
|
resolveWorkspaceRoot,
|
|
69
|
-
targetsByName,
|
|
70
71
|
workspacePath,
|
|
71
72
|
type LoadedProfile,
|
|
72
73
|
type WorkspaceProfiles,
|
|
73
74
|
} from './workspace.ts';
|
|
74
75
|
export {
|
|
75
76
|
LEGACY_TARGET_ENV,
|
|
76
|
-
noTargetInWorkspace,
|
|
77
77
|
noTargetNamed,
|
|
78
|
+
notInRegistry,
|
|
78
79
|
requireTarget,
|
|
79
|
-
|
|
80
|
+
type Registry,
|
|
80
81
|
} from './targets.ts';
|
|
81
|
-
export {
|
|
82
|
+
export {
|
|
83
|
+
isLegacyProfile,
|
|
84
|
+
isLegacyWorkspace,
|
|
85
|
+
legacyConfigSchema,
|
|
86
|
+
legacyTargetSchema,
|
|
87
|
+
type LegacyConfig,
|
|
88
|
+
type LegacyTarget,
|
|
89
|
+
} from './legacy.ts';
|
|
90
|
+
export {
|
|
91
|
+
declaredHere,
|
|
92
|
+
openTarget,
|
|
93
|
+
readRegistry,
|
|
94
|
+
resolveTargetWorkspace,
|
|
95
|
+
type ResolvedTarget,
|
|
96
|
+
} from './registry.ts';
|
|
97
|
+
export { recordTarget, removeTarget } from './deployments.ts';
|
|
82
98
|
export {
|
|
83
99
|
isRemoteWorkspace,
|
|
84
100
|
readWorkspaceFile,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
auditTargetSchema,
|
|
4
|
+
credentialsTargetSchema,
|
|
5
|
+
deployTargetSchema,
|
|
6
|
+
storageTargetSchema,
|
|
7
|
+
vaultTargetSchema,
|
|
8
|
+
} from './schema.ts';
|
|
9
|
+
import { knowledgeTargetSchema } from './knowledge.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Contract 1, understood well enough to migrate away from.
|
|
13
|
+
*
|
|
14
|
+
* The runtime does not read this and must not start. A binary that loaded either
|
|
15
|
+
* shape would be the two-sources-of-truth problem ADR-052 removed, one level up:
|
|
16
|
+
* two spellings of "where does this target live", both valid, disagreeing
|
|
17
|
+
* silently. `SUPPORTED_CONTRACT` is 2 and a contract-1 file is refused
|
|
18
|
+
* everywhere except here.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately loose. It parses only what the migration has to *move* — the
|
|
21
|
+
* `targets:` block and its adapters — and passes everything else through
|
|
22
|
+
* untouched, because the migration edits the YAML document rather than
|
|
23
|
+
* re-rendering it from a parsed shape. A file with a problem this schema cannot
|
|
24
|
+
* see is a file the contract-2 loader will report properly once the structure is
|
|
25
|
+
* right, and that order is on purpose: a stale connection row must not block the
|
|
26
|
+
* structural fix.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `cloudrun:` block `deploy:` replaced.
|
|
31
|
+
*
|
|
32
|
+
* Normalised here rather than in `schema.ts`, which is where it used to live.
|
|
33
|
+
* Contract 2 has no reason to carry a spelling nothing has written for two
|
|
34
|
+
* releases, and the migration is the last thing that will ever read one.
|
|
35
|
+
*/
|
|
36
|
+
const legacyCloudRunSchema = z.object({
|
|
37
|
+
project: z.string(),
|
|
38
|
+
region: z.string(),
|
|
39
|
+
service: z.string(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const legacyTargetSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
credentials: credentialsTargetSchema,
|
|
45
|
+
audit: auditTargetSchema.optional(),
|
|
46
|
+
storage: storageTargetSchema,
|
|
47
|
+
vault: vaultTargetSchema.optional(),
|
|
48
|
+
knowledge: knowledgeTargetSchema.optional(),
|
|
49
|
+
deploy: deployTargetSchema.optional(),
|
|
50
|
+
cloudrun: legacyCloudRunSchema.optional(),
|
|
51
|
+
})
|
|
52
|
+
.transform(({ cloudrun, ...target }) =>
|
|
53
|
+
target.deploy || !cloudrun
|
|
54
|
+
? target
|
|
55
|
+
: {
|
|
56
|
+
...target,
|
|
57
|
+
// The pre-`deploy` spelling predates both of these, so it gets the
|
|
58
|
+
// same defaults the current one would: the closed door, and no
|
|
59
|
+
// instance kept warm.
|
|
60
|
+
deploy: {
|
|
61
|
+
...cloudrun,
|
|
62
|
+
platform: 'cloudrun' as const,
|
|
63
|
+
access: 'iam' as const,
|
|
64
|
+
min_instances: 0,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** A contract-1 profile, as far as the migration needs to understand one. */
|
|
70
|
+
export const legacyConfigSchema = z.object({
|
|
71
|
+
contract: z.literal(1),
|
|
72
|
+
instance: z.object({ profile: z.string() }).passthrough(),
|
|
73
|
+
targets: z.record(z.string(), legacyTargetSchema).default({}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export type LegacyTarget = z.infer<typeof legacyTargetSchema>;
|
|
77
|
+
export type LegacyConfig = z.infer<typeof legacyConfigSchema>;
|
|
78
|
+
|
|
79
|
+
/** Whether a parsed document is a contract-1 profile, without throwing on one. */
|
|
80
|
+
export function isLegacyProfile(raw: unknown): boolean {
|
|
81
|
+
return (
|
|
82
|
+
raw !== null &&
|
|
83
|
+
typeof raw === 'object' &&
|
|
84
|
+
!Array.isArray(raw) &&
|
|
85
|
+
(raw as { contract?: unknown }).contract === 1
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a parsed workspace file is contract 1. Same test, different file. */
|
|
90
|
+
export function isLegacyWorkspace(raw: unknown): boolean {
|
|
91
|
+
return isLegacyProfile(raw);
|
|
92
|
+
}
|
package/src/profile/load.ts
CHANGED
|
@@ -129,7 +129,7 @@ function formatZodIssues(error: z.ZodError): string {
|
|
|
129
129
|
* A connection naming a provider whose id has moved out from under it.
|
|
130
130
|
*
|
|
131
131
|
* There is exactly one, and it is the reason this function exists: `tasks` was
|
|
132
|
-
* Google Tasks until the built-in task list took the plain noun (ADR-
|
|
132
|
+
* Google Tasks until the built-in task list took the plain noun (ADR-052). A row
|
|
133
133
|
* left saying `provider: tasks` does not fail — it resolves to the *built-in*,
|
|
134
134
|
* `reconcile` marks it active because a provider needing no credential is
|
|
135
135
|
* authorized by construction, and the operator is left with their Google Tasks
|
|
@@ -200,15 +200,15 @@ export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
|
|
|
200
200
|
/**
|
|
201
201
|
* The repair, spelled with the selection it will refuse without.
|
|
202
202
|
*
|
|
203
|
-
*
|
|
204
|
-
* that is running
|
|
205
|
-
* in the file.
|
|
206
|
-
*
|
|
203
|
+
* The profile comes off the document being validated rather than off the command
|
|
204
|
+
* that is running, because nothing has resolved anything yet and the name is
|
|
205
|
+
* written in the file. The target cannot come from there any more — a profile
|
|
206
|
+
* declares none (ADR-052) — so it stays a placeholder. That is honest rather
|
|
207
|
+
* than lossy: the file being repaired lives in exactly one target's workspace,
|
|
208
|
+
* and whoever is reading this refusal just typed which one.
|
|
207
209
|
*/
|
|
208
210
|
function repairCommand(config: Config): string {
|
|
209
|
-
|
|
210
|
-
const target = targets.length === 1 ? targets[0] : `<${targets.join('|') || 'target'}>`;
|
|
211
|
-
return `lanes link doctor --fix --profile ${config.instance.profile} --target ${target}`;
|
|
211
|
+
return `lanes link doctor --fix --profile ${config.instance.profile} --target <target>`;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/** The rename a row is owed, or `null` when it is owed none. */
|
|
@@ -241,26 +241,10 @@ function renamedProvider(
|
|
|
241
241
|
function assertReferentialIntegrity(config: Config, source: string): void {
|
|
242
242
|
const problems: string[] = [];
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
// `instance.default_target` is deliberately not checked. Nothing reads it
|
|
249
|
-
// (ADR-037), so validating it would be validating a comment — and failing
|
|
250
|
-
// `check` on a stale value would teach that the key still matters.
|
|
251
|
-
|
|
252
|
-
// Only what holds for every platform. What one platform needs and the next
|
|
253
|
-
// has no concept of — a GCP project, an AWS role ARN — is refused by the
|
|
254
|
-
// driver that needs it, the way an adapter-specific field is refused by the
|
|
255
|
-
// code that opens the adapter rather than by this file.
|
|
256
|
-
for (const [name, target] of Object.entries(config.targets)) {
|
|
257
|
-
if (!target.deploy) continue;
|
|
258
|
-
for (const field of ['region', 'service'] as const) {
|
|
259
|
-
if (!target.deploy[field]) {
|
|
260
|
-
problems.push(`targets.${name}.deploy.${field}: required for a deployable target`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
244
|
+
// There is nothing to check about targets here any more. A profile declares
|
|
245
|
+
// none (ADR-052): the workspace holding this file declares the one target it
|
|
246
|
+
// lives in, and `workspaceSchema` is what validates that. A profile is now
|
|
247
|
+
// portable between targets precisely because it says nothing about them.
|
|
264
248
|
|
|
265
249
|
// Connection ids are unique per provider, so `gmail.main` and
|
|
266
250
|
// `icloud_mail.main` can coexist.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ConfigError } from './load.ts';
|
|
2
|
+
import { notInRegistry } from './targets.ts';
|
|
3
|
+
import { readWorkspace } from './workspace.ts';
|
|
4
|
+
import {
|
|
5
|
+
SUPPORTED_CONTRACT,
|
|
6
|
+
declaredTarget,
|
|
7
|
+
isPointer,
|
|
8
|
+
type TargetConfig,
|
|
9
|
+
type WorkspaceTarget,
|
|
10
|
+
} from './schema.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The target registry: which targets exist, and which workspace declares each.
|
|
14
|
+
*
|
|
15
|
+
* A target names an adapter set. Under contract 1 a *profile* declared one per
|
|
16
|
+
* target it could be opened against, which is what made a deploy leave two
|
|
17
|
+
* copies of every profile — one in `~/.lanes-link`, one in the bucket the
|
|
18
|
+
* endpoint reads — with nothing keeping them honest. The reported failure is in
|
|
19
|
+
* `sync-apply.ts`'s header and it happened again while this was being written:
|
|
20
|
+
* a rewritten local file reported seven connections for a target whose bucket
|
|
21
|
+
* held fifteen, and the endpoint went on serving all fifteen throughout.
|
|
22
|
+
*
|
|
23
|
+
* Now a workspace **is** a target (ADR-052). It declares its adapters once, in
|
|
24
|
+
* its own `lanes-link.yaml`, and holds the profiles that live in it. A profile
|
|
25
|
+
* is one copy in one place, so there is no reconciliation left to get wrong.
|
|
26
|
+
*
|
|
27
|
+
* A machine reaches a target it does not hold through a **pointer** — a registry
|
|
28
|
+
* entry carrying `workspace:` and nothing else. Following one is a read of that
|
|
29
|
+
* workspace's own file, which is why every function here is async and why
|
|
30
|
+
* `--target cloud` needs the bucket reachable. That is the trade ADR-052 takes
|
|
31
|
+
* deliberately: a cloud target that cannot be read says so, where the shape it
|
|
32
|
+
* replaces answered instantly from a copy that had been wrong for eight hours.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Everything a command needs once it knows which target it is acting on. */
|
|
36
|
+
export interface ResolvedTarget {
|
|
37
|
+
readonly target: string;
|
|
38
|
+
/**
|
|
39
|
+
* Where this target's profiles and workspace file live — a directory, or a
|
|
40
|
+
* bucket URL. Not necessarily the root the command was invoked from.
|
|
41
|
+
*/
|
|
42
|
+
readonly workspaceRoot: string;
|
|
43
|
+
/** The adapter set, from whichever workspace declares it. */
|
|
44
|
+
readonly declared: TargetConfig;
|
|
45
|
+
/** The declaring entry, for `primary` and `last_deploy`. */
|
|
46
|
+
readonly entry: WorkspaceTarget;
|
|
47
|
+
/** Whether the local workspace reached this through a pointer. */
|
|
48
|
+
readonly remote: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every target the workspace at `root` knows about. Empty when it has no file. */
|
|
52
|
+
export async function readRegistry(root: string): Promise<Record<string, WorkspaceTarget>> {
|
|
53
|
+
const workspace = await readWorkspace(root);
|
|
54
|
+
return workspace?.targets ?? {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Follow a target to the workspace that declares it.
|
|
59
|
+
*
|
|
60
|
+
* Returns `root` itself for a target this workspace declares, and the pointer's
|
|
61
|
+
* URI for one it does not. One hop only: a pointer whose destination is itself a
|
|
62
|
+
* pointer is a loop, and a registry that can chain is one where "where does this
|
|
63
|
+
* live" stops having a short answer.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveTargetWorkspace(root: string, target: string): Promise<string> {
|
|
66
|
+
const registry = await readRegistry(root);
|
|
67
|
+
const entry = registry[target];
|
|
68
|
+
if (!entry) throw notInRegistry(target, registry, root);
|
|
69
|
+
return isPointer(entry) ? entry.workspace.replace(/\/$/, '') : root;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A target, resolved to the adapter set a command can open.
|
|
74
|
+
*
|
|
75
|
+
* The pointer hop is the only thing here that touches the network, and it
|
|
76
|
+
* happens once per command rather than per store — `openSecretStoreFor` and
|
|
77
|
+
* `openBlobStoreFor` take what this returns rather than resolving again.
|
|
78
|
+
*/
|
|
79
|
+
export async function openTarget(root: string, target: string): Promise<ResolvedTarget> {
|
|
80
|
+
const registry = await readRegistry(root);
|
|
81
|
+
const entry = registry[target];
|
|
82
|
+
if (!entry) throw notInRegistry(target, registry, root);
|
|
83
|
+
|
|
84
|
+
if (!isPointer(entry)) {
|
|
85
|
+
const declared = declaredTarget(entry);
|
|
86
|
+
// Unreachable through the schema, which refuses an entry that is neither a
|
|
87
|
+
// pointer nor a complete declaration. Kept because the alternative to a
|
|
88
|
+
// sentence here is a `TypeError` inside an adapter three frames down.
|
|
89
|
+
if (!declared) throw incompleteTarget(target, root);
|
|
90
|
+
return { target, workspaceRoot: root, declared, entry, remote: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const workspaceRoot = entry.workspace.replace(/\/$/, '');
|
|
94
|
+
const remoteRegistry = await readRegistry(workspaceRoot);
|
|
95
|
+
const remoteEntry = remoteRegistry[target];
|
|
96
|
+
|
|
97
|
+
if (!remoteEntry) {
|
|
98
|
+
// A contract-1 workspace has no `targets:` block at all, so it looks exactly
|
|
99
|
+
// like one that declares the wrong things. Told apart here because the two
|
|
100
|
+
// have completely different fixes, and "does not declare it" would send
|
|
101
|
+
// someone editing a bucket that is merely out of date.
|
|
102
|
+
if (await isUnmigrated(workspaceRoot)) throw remoteAtContractOne(target, workspaceRoot);
|
|
103
|
+
throw pointerMissesTarget(target, workspaceRoot, remoteRegistry, root);
|
|
104
|
+
}
|
|
105
|
+
if (isPointer(remoteEntry)) throw pointerChain(target, workspaceRoot, root);
|
|
106
|
+
|
|
107
|
+
const declared = declaredTarget(remoteEntry);
|
|
108
|
+
if (!declared) throw incompleteTarget(target, workspaceRoot);
|
|
109
|
+
|
|
110
|
+
// The local entry's `primary` and `last_deploy` are what `deploy` wrote on the
|
|
111
|
+
// machine that ran it; the declaring workspace is authoritative for everything
|
|
112
|
+
// else. Merged this way round so a redeploy from a second machine does not
|
|
113
|
+
// silently lose the first one's record of who opens the endpoint.
|
|
114
|
+
return {
|
|
115
|
+
target,
|
|
116
|
+
workspaceRoot,
|
|
117
|
+
declared,
|
|
118
|
+
entry: { ...entry, ...remoteEntry },
|
|
119
|
+
remote: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The targets a workspace declares itself, rather than pointing at. */
|
|
124
|
+
export function declaredHere(registry: Record<string, WorkspaceTarget>): string[] {
|
|
125
|
+
return Object.entries(registry)
|
|
126
|
+
.filter(([, entry]) => !isPointer(entry))
|
|
127
|
+
.map(([name]) => name)
|
|
128
|
+
.sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function incompleteTarget(target: string, root: string): ConfigError {
|
|
132
|
+
return new ConfigError(
|
|
133
|
+
`Target "${target}" in ${root} declares neither "credentials" nor "storage", ` +
|
|
134
|
+
'so there is nothing to open.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pointerMissesTarget(
|
|
139
|
+
target: string,
|
|
140
|
+
workspaceRoot: string,
|
|
141
|
+
remote: Record<string, WorkspaceTarget>,
|
|
142
|
+
root: string,
|
|
143
|
+
): ConfigError {
|
|
144
|
+
const there = Object.keys(remote).sort().join(', ') || 'none';
|
|
145
|
+
return new ConfigError(
|
|
146
|
+
`${root} says target "${target}" lives at ${workspaceRoot}, but that workspace does not ` +
|
|
147
|
+
`declare it (it declares: ${there}).\n` +
|
|
148
|
+
` Adopt what is really there: lanes link sync targets --target ${target} --from ${workspaceRoot}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pointerChain(target: string, workspaceRoot: string, root: string): ConfigError {
|
|
153
|
+
return new ConfigError(
|
|
154
|
+
`${root} points target "${target}" at ${workspaceRoot}, which points somewhere else again.\n` +
|
|
155
|
+
' A target is declared by exactly one workspace. Follow it and declare it there.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a workspace still holds contract-1 profiles.
|
|
162
|
+
*
|
|
163
|
+
* Duplicated in spirit with `workspace-migrate.ts`'s `needsMigration`, and not
|
|
164
|
+
* imported from it: `#profile` is below `#cli` in the layering, and a refusal
|
|
165
|
+
* reaching upwards for a sentence is how a cycle gets introduced. This looks at
|
|
166
|
+
* one file rather than every profile, which is all a refusal needs.
|
|
167
|
+
*/
|
|
168
|
+
async function isUnmigrated(root: string): Promise<boolean> {
|
|
169
|
+
const workspace = await readWorkspace(root).catch(() => null);
|
|
170
|
+
if (workspace === null) return false;
|
|
171
|
+
return workspace.contract < SUPPORTED_CONTRACT;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function remoteAtContractOne(target: string, workspaceRoot: string): ConfigError {
|
|
175
|
+
return new ConfigError(
|
|
176
|
+
`${workspaceRoot} is a contract 1 workspace, so it does not declare "${target}" yet.\n` +
|
|
177
|
+
' Its profiles still carry their own targets: block, which this version does not read.\n\n' +
|
|
178
|
+
` lanes link deploy --target ${target}\n` +
|
|
179
|
+
' migrates it and rolls the image that can read it, in that order — which is what\n' +
|
|
180
|
+
' keeps the endpoint in front of it serving throughout (ADR-052).',
|
|
181
|
+
);
|
|
182
|
+
}
|