@monster-orange/den 0.2.8 → 0.3.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/CHANGELOG.md +1547 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +507 -4
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +141 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +7 -2
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +548 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +22 -1
- package/src/ui/mcp/tools.ts +50 -7
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +532 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +12 -1
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +45 -3
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who holds a stack's Compose project: the refcount a shared stack
|
|
3
|
+
* namespace needs (namespace.ts).
|
|
4
|
+
*
|
|
5
|
+
* Without `stack_namespace` each workspace's projects are its own
|
|
6
|
+
* (`<stack>-<workspace>`), so one workspace holds each. Two workspaces with
|
|
7
|
+
* the same namespace that bring up the same stack share one project, and
|
|
8
|
+
* one of them stopping it must not stop it for the other. This mirrors the
|
|
9
|
+
* owner refcount of Den's own services (a daemon's `workspaces[]`,
|
|
10
|
+
* releaseWorkspaceDaemons):
|
|
11
|
+
*
|
|
12
|
+
* - `stack up` from workspace W records W as an owner, with the checkout
|
|
13
|
+
* (repo path and branch) the project runs from;
|
|
14
|
+
* - a later `up` from another workspace on the same checkout joins; from a
|
|
15
|
+
* different checkout or branch it is refused (`identity_mismatch`), never
|
|
16
|
+
* a silent swap of containers;
|
|
17
|
+
* - `stack down` from W stops the project only when no other live owner
|
|
18
|
+
* remains; otherwise it detaches W and leaves the project running.
|
|
19
|
+
*
|
|
20
|
+
* A live owner is a workspace that is still active, still resolves to this
|
|
21
|
+
* namespace and still holds the stack: re-derived on every decision, so a
|
|
22
|
+
* deactivated or moved workspace never wedges a project. A record whose
|
|
23
|
+
* project has verifiably stopped counts no one.
|
|
24
|
+
*
|
|
25
|
+
* The record is `owners.json` in the project's own state directory, next
|
|
26
|
+
* to its generated.env and override.yml. DEN_STACK_WORKSPACE (a
|
|
27
|
+
* per-command override) bypasses it: such a command neither records nor
|
|
28
|
+
* counts owners, as before.
|
|
29
|
+
*/
|
|
30
|
+
import { spawnSync } from "child_process";
|
|
31
|
+
import * as fs from "fs";
|
|
32
|
+
import * as path from "path";
|
|
33
|
+
import { getReposRoot } from "../den-config.js";
|
|
34
|
+
import { readCatalog } from "../environment-manager/catalog.js";
|
|
35
|
+
import {
|
|
36
|
+
getActiveWorkspaces,
|
|
37
|
+
getWorkspaceByName,
|
|
38
|
+
listWorkspaceServices,
|
|
39
|
+
} from "../environment-manager/workspaces.js";
|
|
40
|
+
import { ServiceConflictError } from "../environment-manager/service-runner.js";
|
|
41
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
42
|
+
import { manifestStackName, readStacks } from "./config.js";
|
|
43
|
+
import { isPrimary } from "./manifest-commands.js";
|
|
44
|
+
import { resolveStackNamespace } from "./namespace.js";
|
|
45
|
+
import { projectName } from "./render.js";
|
|
46
|
+
import { stackDir, type StackContext } from "./runtime.js";
|
|
47
|
+
import { loadStackContext } from "./status.js";
|
|
48
|
+
import type { StackConfig } from "./types.js";
|
|
49
|
+
|
|
50
|
+
/** A checkout: what a shared project must run from for every owner. */
|
|
51
|
+
export interface StackCheckout {
|
|
52
|
+
repoPath: string;
|
|
53
|
+
branch: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface OwnersRecord extends StackCheckout {
|
|
57
|
+
project: string;
|
|
58
|
+
workspaces: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Lookups the refcount needs; injectable for tests. */
|
|
62
|
+
export interface StackOwnerDeps {
|
|
63
|
+
active?: () => string[];
|
|
64
|
+
/** A workspace's stack namespace; null when it cannot be resolved. */
|
|
65
|
+
namespaceOf?: (workspace: string) => string | null;
|
|
66
|
+
/** Whether a workspace still holds the stack. */
|
|
67
|
+
holds?: (workspace: string, stack: string) => boolean;
|
|
68
|
+
/** Whether the project has running containers; null: docker can't tell. */
|
|
69
|
+
running?: (project: string) => boolean | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether a context takes part in the refcount (not under the override). */
|
|
73
|
+
export function stackRefcounted(
|
|
74
|
+
ctx: StackContext,
|
|
75
|
+
): ctx is StackContext & { denWorkspace: string } {
|
|
76
|
+
return !!ctx.denWorkspace && ctx.namespaceSource !== "env";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ownersFile(ctx: StackContext, s: StackConfig): string {
|
|
80
|
+
return path.join(stackDir(ctx, s), "owners.json");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readRecord(ctx: StackContext, s: StackConfig): OwnersRecord | null {
|
|
84
|
+
try {
|
|
85
|
+
const raw = JSON.parse(
|
|
86
|
+
fs.readFileSync(ownersFile(ctx, s), "utf-8"),
|
|
87
|
+
) as Partial<OwnersRecord>;
|
|
88
|
+
if (typeof raw.repoPath !== "string" || !Array.isArray(raw.workspaces))
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
project: projectName(s.name, ctx.workspace),
|
|
92
|
+
repoPath: raw.repoPath,
|
|
93
|
+
branch: typeof raw.branch === "string" ? raw.branch : null,
|
|
94
|
+
workspaces: raw.workspaces.filter(
|
|
95
|
+
(w): w is string => typeof w === "string",
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeRecord(
|
|
104
|
+
ctx: StackContext,
|
|
105
|
+
s: StackConfig,
|
|
106
|
+
rec: OwnersRecord,
|
|
107
|
+
): void {
|
|
108
|
+
const file = ownersFile(ctx, s);
|
|
109
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
110
|
+
fs.writeFileSync(file, JSON.stringify(rec, null, 2) + "\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Drop the record once the project is down. */
|
|
114
|
+
export function forgetStackOwners(ctx: StackContext, s: StackConfig): void {
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(ownersFile(ctx, s));
|
|
117
|
+
} catch {
|
|
118
|
+
/* none recorded */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The checkout `s` runs from in `workspace`: its repo path and branch. */
|
|
123
|
+
export function stackCheckout(
|
|
124
|
+
workspace: string,
|
|
125
|
+
s: StackConfig,
|
|
126
|
+
): StackCheckout {
|
|
127
|
+
const repoPath = s.repoPath ?? path.join(getReposRoot(), s.repo);
|
|
128
|
+
let branch: string | null = null;
|
|
129
|
+
try {
|
|
130
|
+
const ws = getWorkspaceByName(workspace);
|
|
131
|
+
if (ws)
|
|
132
|
+
branch =
|
|
133
|
+
listWorkspaceServices(ws.config, readCatalog(), ws.name).find(
|
|
134
|
+
(svc) => svc.repoPath === repoPath,
|
|
135
|
+
)?.branch ?? null;
|
|
136
|
+
} catch {
|
|
137
|
+
/* no branch to tell */
|
|
138
|
+
}
|
|
139
|
+
return { repoPath, branch };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const same = (a: StackCheckout, b: StackCheckout): boolean =>
|
|
143
|
+
a.repoPath === b.repoPath && (a.branch ?? null) === (b.branch ?? null);
|
|
144
|
+
|
|
145
|
+
function composeRunning(project: string): boolean | null {
|
|
146
|
+
const r = spawnSync(
|
|
147
|
+
"docker",
|
|
148
|
+
["ps", "-q", "--filter", `label=com.docker.compose.project=${project}`],
|
|
149
|
+
{ encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] },
|
|
150
|
+
);
|
|
151
|
+
if (r.error || r.status !== 0) return null;
|
|
152
|
+
return (r.stdout ?? "").trim().length > 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const defaultDeps: Required<StackOwnerDeps> = {
|
|
156
|
+
active: getActiveWorkspaces,
|
|
157
|
+
namespaceOf: (w) => {
|
|
158
|
+
try {
|
|
159
|
+
return resolveStackNamespace(w).namespace;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
holds: (w, stack) => {
|
|
165
|
+
try {
|
|
166
|
+
if (!getWorkspaceByName(w)) return false;
|
|
167
|
+
return stack in readStacks(undefined, { workspace: w });
|
|
168
|
+
} catch {
|
|
169
|
+
return true; // can't tell: never stop it from under them
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
running: composeRunning,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The record and the live owners other than `workspace`. */
|
|
176
|
+
function coOwners(
|
|
177
|
+
ctx: StackContext,
|
|
178
|
+
s: StackConfig,
|
|
179
|
+
workspace: string,
|
|
180
|
+
deps: StackOwnerDeps,
|
|
181
|
+
): { record: OwnersRecord | null; others: string[] } {
|
|
182
|
+
const d = { ...defaultDeps, ...deps };
|
|
183
|
+
const record = readRecord(ctx, s);
|
|
184
|
+
if (!record) return { record, others: [] };
|
|
185
|
+
const active = new Set(d.active());
|
|
186
|
+
const others = record.workspaces.filter(
|
|
187
|
+
(w) =>
|
|
188
|
+
w !== workspace &&
|
|
189
|
+
active.has(w) &&
|
|
190
|
+
d.namespaceOf(w) === ctx.workspace &&
|
|
191
|
+
d.holds(w, s.name),
|
|
192
|
+
);
|
|
193
|
+
// A project that has verifiably stopped is held by no one.
|
|
194
|
+
if (others.length && d.running(record.project) === false)
|
|
195
|
+
return { record, others: [] };
|
|
196
|
+
return { record, others };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Check that `workspace` may bring `s` up: no other live owner runs its
|
|
201
|
+
* project from a different checkout or branch. Returns those other owners
|
|
202
|
+
* (sharing it); throws ServiceConflictError (`identity_mismatch`) when one
|
|
203
|
+
* runs it from elsewhere.
|
|
204
|
+
*/
|
|
205
|
+
export function assertStackClaim(
|
|
206
|
+
ctx: StackContext,
|
|
207
|
+
s: StackConfig,
|
|
208
|
+
workspace: string,
|
|
209
|
+
deps: StackOwnerDeps = {},
|
|
210
|
+
): string[] {
|
|
211
|
+
const { record, others } = coOwners(ctx, s, workspace, deps);
|
|
212
|
+
if (!record || others.length === 0) return [];
|
|
213
|
+
const requested = stackCheckout(workspace, s);
|
|
214
|
+
if (same(record, requested)) return others;
|
|
215
|
+
const project = projectName(s.name, ctx.workspace);
|
|
216
|
+
const n = (b: string | null) => b ?? "n/a";
|
|
217
|
+
throw new ServiceConflictError({
|
|
218
|
+
reason: "identity_mismatch",
|
|
219
|
+
address: s.name,
|
|
220
|
+
owners: others,
|
|
221
|
+
running: { repoPath: record.repoPath, branch: record.branch },
|
|
222
|
+
requested,
|
|
223
|
+
message:
|
|
224
|
+
`stack ${s.name}: Compose project ${project} (stack namespace "${ctx.workspace}") ` +
|
|
225
|
+
`is already running for workspace(s) [${others.join(", ")}] at ${record.repoPath} ` +
|
|
226
|
+
`(branch: ${n(record.branch)}), which differs from ${requested.repoPath} ` +
|
|
227
|
+
`(branch: ${n(requested.branch)}) for workspace "${workspace}". ` +
|
|
228
|
+
`Not swapping its containers: stop it there first, or give "${workspace}" its own stack_namespace`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Record `workspace` as an owner of `s`'s project after it is up. Joins the
|
|
234
|
+
* record on the same checkout; a different checkout (reached only once no
|
|
235
|
+
* live owner remains, see assertStackClaim) starts a new one.
|
|
236
|
+
*/
|
|
237
|
+
export function recordStackOwner(
|
|
238
|
+
ctx: StackContext,
|
|
239
|
+
s: StackConfig,
|
|
240
|
+
workspace: string,
|
|
241
|
+
): void {
|
|
242
|
+
const checkout = stackCheckout(workspace, s);
|
|
243
|
+
const prev = readRecord(ctx, s);
|
|
244
|
+
const kept = prev && same(prev, checkout) ? prev.workspaces : [];
|
|
245
|
+
writeRecord(ctx, s, {
|
|
246
|
+
project: projectName(s.name, ctx.workspace),
|
|
247
|
+
...checkout,
|
|
248
|
+
workspaces: [...new Set([...kept, workspace])],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `workspace` lets go of `s`'s project. `stop: false` with `remaining`
|
|
254
|
+
* when other live owners still hold it (they stay recorded, re-derived);
|
|
255
|
+
* `stop: true` when it was the last (call forgetStackOwners once down).
|
|
256
|
+
*/
|
|
257
|
+
export function releaseStackOwner(
|
|
258
|
+
ctx: StackContext,
|
|
259
|
+
s: StackConfig,
|
|
260
|
+
workspace: string,
|
|
261
|
+
deps: StackOwnerDeps = {},
|
|
262
|
+
): { stop: boolean; remaining: string[] } {
|
|
263
|
+
const { record, others } = coOwners(ctx, s, workspace, deps);
|
|
264
|
+
if (!record || others.length === 0) return { stop: true, remaining: [] };
|
|
265
|
+
writeRecord(ctx, s, { ...record, workspaces: others });
|
|
266
|
+
return { stop: false, remaining: others };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Record `workspace` as an owner of a compose stack it is about to skip
|
|
271
|
+
* because it already answers its health check (claimHealthyService).
|
|
272
|
+
* `attached` when it now shares the project with another workspace; null
|
|
273
|
+
* when it holds it alone (recorded all the same), when `resolved` is not a
|
|
274
|
+
* stack's primary service, or under the override;
|
|
275
|
+
* `conflict` when the running project is another checkout's (the caller
|
|
276
|
+
* then runs the real `up`, which refuses with the identity mismatch).
|
|
277
|
+
*/
|
|
278
|
+
export function claimHealthyStack(
|
|
279
|
+
resolved: { repoPath: string; repoName: string; subsystem: string },
|
|
280
|
+
workspace: string,
|
|
281
|
+
deps: StackOwnerDeps = {},
|
|
282
|
+
): "attached" | "conflict" | null {
|
|
283
|
+
try {
|
|
284
|
+
const m = readManifest(resolved.repoPath);
|
|
285
|
+
if (!m?.compose || !isPrimary(m, resolved.subsystem)) return null;
|
|
286
|
+
const ctx = loadStackContext(workspace);
|
|
287
|
+
if (!stackRefcounted(ctx)) return null;
|
|
288
|
+
const s = ctx.stacks[manifestStackName(m, resolved.repoName)];
|
|
289
|
+
if (!s) return null;
|
|
290
|
+
let others: string[];
|
|
291
|
+
try {
|
|
292
|
+
others = assertStackClaim(ctx, s, workspace, deps);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
if (e instanceof ServiceConflictError) return "conflict";
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
recordStackOwner(ctx, s, workspace);
|
|
298
|
+
// Held by this workspace alone: recorded, and said as before.
|
|
299
|
+
return others.length ? "attached" : null;
|
|
300
|
+
} catch {
|
|
301
|
+
return null; // no edge, an unreadable manifest: not a stack we can tell
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
* override, the link networks and the edge routes. No I/O.
|
|
4
4
|
*/
|
|
5
5
|
import * as yaml from "yaml";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
edgeDomains,
|
|
8
|
+
routeEntryPoint,
|
|
9
|
+
routeHost,
|
|
10
|
+
routerTls,
|
|
11
|
+
} from "../edge/render.js";
|
|
7
12
|
import type { EdgeConfig } from "../edge/types.js";
|
|
8
13
|
import type { ResolvedProject, StackConfig } from "./types.js";
|
|
9
14
|
|
|
@@ -220,12 +225,12 @@ export function renderStackRoutes(
|
|
|
220
225
|
const { service, port } = parseTarget(target);
|
|
221
226
|
const key = `stack-${stack.name}-${route}`;
|
|
222
227
|
routers[key] = {
|
|
223
|
-
entryPoints: [
|
|
228
|
+
entryPoints: [routeEntryPoint(edge)],
|
|
224
229
|
rule: edgeDomains(edge)
|
|
225
230
|
.map((d) => `Host(\`${routeHost(`${route}.${stack.name}`, d)}\`)`)
|
|
226
231
|
.join(" || "),
|
|
227
232
|
service: key,
|
|
228
|
-
|
|
233
|
+
...routerTls(edge),
|
|
229
234
|
};
|
|
230
235
|
services[key] = {
|
|
231
236
|
loadBalancer: {
|
|
@@ -60,10 +60,16 @@ function must(
|
|
|
60
60
|
export interface StackContext {
|
|
61
61
|
edge: EdgeConfig;
|
|
62
62
|
stacks: Record<string, StackConfig>;
|
|
63
|
+
/** The Compose project suffix (the stack namespace). */
|
|
63
64
|
workspace: string;
|
|
65
|
+
/** The den workspace the stacks were read for (loadStackContext). */
|
|
66
|
+
denWorkspace?: string;
|
|
67
|
+
/** Where the suffix came from; `env`: DEN_STACK_WORKSPACE. */
|
|
68
|
+
namespaceSource?: "workspace" | "blueprint" | "default" | "env";
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
/** The project's local state: generated.env, override.yml, owners.json. */
|
|
72
|
+
export function stackDir(ctx: StackContext, s: StackConfig): string {
|
|
67
73
|
return path.join(
|
|
68
74
|
ctx.edge.stateDir,
|
|
69
75
|
"stacks",
|
|
@@ -193,6 +199,94 @@ function connect(network: string, container: string): void {
|
|
|
193
199
|
throw new Error(`docker network connect ${network} ${container}: ${r.err}`);
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Connect the edge to each of `nets` it is not on (`on`: the networks it
|
|
204
|
+
* is on already; unknown, every one is connected, "already connected"
|
|
205
|
+
* being fine). Returns the networks it joined. stack up and the rejoin
|
|
206
|
+
* after edge up share it.
|
|
207
|
+
*/
|
|
208
|
+
function joinEdge(
|
|
209
|
+
edge: EdgeConfig,
|
|
210
|
+
nets: string[],
|
|
211
|
+
on: ReadonlySet<string> = new Set(),
|
|
212
|
+
): string[] {
|
|
213
|
+
const container = edgeNames(edge.org).container;
|
|
214
|
+
const joined: string[] = [];
|
|
215
|
+
for (const n of nets) {
|
|
216
|
+
if (on.has(n)) continue;
|
|
217
|
+
connect(n, container);
|
|
218
|
+
joined.push(n);
|
|
219
|
+
}
|
|
220
|
+
return joined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The dynamic file a running stack's routes live in, under this edge's state dir. */
|
|
224
|
+
function stackRoutesFile(edge: EdgeConfig, project: string): string {
|
|
225
|
+
return path.join(edge.stateDir, "dynamic", `stack-${project}.yml`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* After edge up (re)creates or starts the edge, put it back on every
|
|
230
|
+
* running stack's routed networks (0.3.0-dev.40): a recreated container
|
|
231
|
+
* starts on its own network only, and each stack's routes 502 until that
|
|
232
|
+
* stack's next `stack up`. A stack counts when it has its routes file in
|
|
233
|
+
* this edge's dynamic dir and its compose project has a container up;
|
|
234
|
+
* stacks that are down, and projects with no routes file here (another
|
|
235
|
+
* state dir's, or none of Den's), are not touched. Idempotent: a network
|
|
236
|
+
* the edge is on already is left alone. One line per network joined;
|
|
237
|
+
* a stack that cannot be resolved gets a line and is skipped.
|
|
238
|
+
*/
|
|
239
|
+
export function rejoinEdgeNetworks(
|
|
240
|
+
contexts: StackContext[],
|
|
241
|
+
onLine: (line: string) => void = console.log,
|
|
242
|
+
): string[] {
|
|
243
|
+
if (!contexts.length) return [];
|
|
244
|
+
const edge = contexts[0].edge;
|
|
245
|
+
const on = new Set(
|
|
246
|
+
run("docker", [
|
|
247
|
+
"inspect",
|
|
248
|
+
"-f",
|
|
249
|
+
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
|
250
|
+
edgeNames(edge.org).container,
|
|
251
|
+
]).out.split(/\s+/),
|
|
252
|
+
);
|
|
253
|
+
const joined: string[] = [];
|
|
254
|
+
const seen = new Set<string>();
|
|
255
|
+
for (const ctx of contexts) {
|
|
256
|
+
// Only this edge's state dir.
|
|
257
|
+
if (ctx.edge.stateDir !== edge.stateDir) continue;
|
|
258
|
+
for (const s of Object.values(ctx.stacks)) {
|
|
259
|
+
const project = projectName(s.name, ctx.workspace);
|
|
260
|
+
if (seen.has(project)) continue;
|
|
261
|
+
seen.add(project);
|
|
262
|
+
if (!fs.existsSync(stackRoutesFile(edge, project))) continue;
|
|
263
|
+
const up = run("docker", [
|
|
264
|
+
"ps",
|
|
265
|
+
"-q",
|
|
266
|
+
"--filter",
|
|
267
|
+
`label=com.docker.compose.project=${project}`,
|
|
268
|
+
]);
|
|
269
|
+
if (up.code !== 0 || !up.out) continue;
|
|
270
|
+
try {
|
|
271
|
+
for (const n of joinEdge(
|
|
272
|
+
edge,
|
|
273
|
+
routedNetworks(s, resolve(ctx, s)),
|
|
274
|
+
on,
|
|
275
|
+
)) {
|
|
276
|
+
on.add(n);
|
|
277
|
+
joined.push(n);
|
|
278
|
+
onLine(` rejoined ${n} (${project})`);
|
|
279
|
+
}
|
|
280
|
+
} catch (err) {
|
|
281
|
+
onLine(
|
|
282
|
+
` ${project}: not rejoined (${(err as Error).message.split("\n")[0]})`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return joined;
|
|
288
|
+
}
|
|
289
|
+
|
|
196
290
|
function running(container: string): boolean {
|
|
197
291
|
return (
|
|
198
292
|
run("docker", ["inspect", "-f", "{{.State.Running}}", container]).out ===
|
|
@@ -257,29 +351,19 @@ export function stackUp(
|
|
|
257
351
|
for (const svc of providerServices(c)[s.name] ?? [])
|
|
258
352
|
connect(net, containerName(projectName(s.name, ctx.workspace), svc));
|
|
259
353
|
}
|
|
260
|
-
const edge = edgeNames(ctx.edge.org).container;
|
|
261
354
|
const nets = routedNetworks(s, resolved);
|
|
262
|
-
|
|
355
|
+
joinEdge(ctx.edge, nets);
|
|
263
356
|
fs.writeFileSync(
|
|
264
|
-
|
|
265
|
-
ctx.edge.stateDir,
|
|
266
|
-
"dynamic",
|
|
267
|
-
`stack-${projectName(s.name, ctx.workspace)}.yml`,
|
|
268
|
-
),
|
|
357
|
+
stackRoutesFile(ctx.edge, projectName(s.name, ctx.workspace)),
|
|
269
358
|
renderStackRoutes(ctx.edge, s, resolved),
|
|
270
359
|
);
|
|
271
360
|
return { project: resolved.name, networks: nets };
|
|
272
361
|
}
|
|
273
362
|
|
|
274
363
|
export function stackDown(ctx: StackContext, s: StackConfig): void {
|
|
275
|
-
fs.rmSync(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"dynamic",
|
|
279
|
-
`stack-${projectName(s.name, ctx.workspace)}.yml`,
|
|
280
|
-
),
|
|
281
|
-
{ force: true },
|
|
282
|
-
);
|
|
364
|
+
fs.rmSync(stackRoutesFile(ctx.edge, projectName(s.name, ctx.workspace)), {
|
|
365
|
+
force: true,
|
|
366
|
+
});
|
|
283
367
|
const edge = edgeNames(ctx.edge.org).container;
|
|
284
368
|
const dir = repoDir(s);
|
|
285
369
|
const override = path.join(stackDir(ctx, s), "override.yml");
|
|
@@ -10,13 +10,23 @@ import { readCatalog } from "../environment-manager/catalog.js";
|
|
|
10
10
|
import { readEdgeConfig } from "../edge/config.js";
|
|
11
11
|
import { routeUrl } from "../edge/render.js";
|
|
12
12
|
import {
|
|
13
|
+
getActiveWorkspaces,
|
|
13
14
|
getFocusedWorkspaceName,
|
|
14
15
|
getWorkspaceByName,
|
|
15
16
|
listWorkspaceServices,
|
|
16
17
|
} from "../environment-manager/workspaces.js";
|
|
17
18
|
import { readManifestStacks, readStacks } from "./config.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
19
|
+
import { STACK_NAMESPACE_RE } from "../den-config.js";
|
|
20
|
+
import {
|
|
21
|
+
resolveStackNamespace,
|
|
22
|
+
type StackNamespaceSource,
|
|
23
|
+
} from "./namespace.js";
|
|
24
|
+
import { projectName } from "./render.js";
|
|
25
|
+
import {
|
|
26
|
+
rejoinEdgeNetworks,
|
|
27
|
+
stackHealth,
|
|
28
|
+
type StackContext,
|
|
29
|
+
} from "./runtime.js";
|
|
20
30
|
|
|
21
31
|
export interface StackRoute {
|
|
22
32
|
name: string;
|
|
@@ -38,40 +48,81 @@ export interface StackStatus {
|
|
|
38
48
|
health?: { ok: boolean | null; detail: string };
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
const WS_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
42
|
-
|
|
43
51
|
/**
|
|
44
52
|
* The den workspace stacks read their manifests from, and the Compose
|
|
45
53
|
* project suffix they run under. `denWorkspace` defaults to the CLI's
|
|
46
|
-
* -w override, then the focused workspace, then `default`.
|
|
54
|
+
* -w override, then the focused workspace, then `default`. The suffix is
|
|
55
|
+
* the workspace's stack namespace (namespace.ts); DEN_STACK_WORKSPACE, a
|
|
56
|
+
* per-command override, wins over it for this one process (source `env`).
|
|
57
|
+
*
|
|
58
|
+
* The one place a den workspace becomes a Compose project suffix: every
|
|
59
|
+
* stack path (status, runtime, health, container lookup) comes through here.
|
|
47
60
|
*/
|
|
48
61
|
export function resolveStackWorkspaces(
|
|
49
62
|
denWorkspace?: string | null,
|
|
50
63
|
env: NodeJS.ProcessEnv = process.env,
|
|
51
|
-
): {
|
|
64
|
+
): {
|
|
65
|
+
denWorkspace: string;
|
|
66
|
+
stackWorkspace: string;
|
|
67
|
+
source: StackNamespaceSource | "env";
|
|
68
|
+
} {
|
|
52
69
|
const fkWs =
|
|
53
70
|
denWorkspace ||
|
|
54
71
|
env.DEN_WORKSPACE_OVERRIDE ||
|
|
55
72
|
getFocusedWorkspaceName() ||
|
|
56
73
|
"default";
|
|
57
|
-
const
|
|
58
|
-
|
|
74
|
+
const override = env.DEN_STACK_WORKSPACE;
|
|
75
|
+
const resolved = override ? null : resolveStackNamespace(fkWs);
|
|
76
|
+
const ws = override || resolved!.namespace;
|
|
77
|
+
if (!STACK_NAMESPACE_RE.test(ws))
|
|
59
78
|
throw new Error(
|
|
60
79
|
`workspace "${ws}" must be lowercase letters, digits and dashes`,
|
|
61
80
|
);
|
|
62
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
denWorkspace: fkWs,
|
|
83
|
+
stackWorkspace: ws,
|
|
84
|
+
source: resolved ? resolved.source : "env",
|
|
85
|
+
};
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/** The edge, the known stacks and the project suffix for one workspace. */
|
|
66
89
|
export function loadStackContext(denWorkspace?: string | null): StackContext {
|
|
67
|
-
const {
|
|
68
|
-
|
|
90
|
+
const {
|
|
91
|
+
denWorkspace: fkWs,
|
|
92
|
+
stackWorkspace: ws,
|
|
93
|
+
source,
|
|
94
|
+
} = resolveStackWorkspaces(denWorkspace);
|
|
69
95
|
const edge = readEdgeConfig();
|
|
70
96
|
// Repo manifests come from that den workspace's checkouts.
|
|
71
97
|
const stacks = readStacks(undefined, {
|
|
72
98
|
workspace: getWorkspaceByName(fkWs) ? fkWs : undefined,
|
|
73
99
|
});
|
|
74
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
edge,
|
|
102
|
+
stacks,
|
|
103
|
+
workspace: ws,
|
|
104
|
+
denWorkspace: fkWs,
|
|
105
|
+
namespaceSource: source,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* After edge up: the edge back on the routed networks of every running
|
|
111
|
+
* stack it routes (rejoinEdgeNetworks), across the focused and active
|
|
112
|
+
* workspaces' stack namespaces. A workspace whose stacks cannot be read is
|
|
113
|
+
* skipped.
|
|
114
|
+
*/
|
|
115
|
+
export function rejoinRunningStacks(
|
|
116
|
+
onLine: (line: string) => void = console.log,
|
|
117
|
+
): string[] {
|
|
118
|
+
const contexts: StackContext[] = [];
|
|
119
|
+
for (const ws of new Set([null, ...getActiveWorkspaces()]))
|
|
120
|
+
try {
|
|
121
|
+
contexts.push(loadStackContext(ws));
|
|
122
|
+
} catch {
|
|
123
|
+
/* unreadable: nothing of it to rejoin */
|
|
124
|
+
}
|
|
125
|
+
return rejoinEdgeNetworks(contexts, onLine);
|
|
75
126
|
}
|
|
76
127
|
|
|
77
128
|
/** Each stack's name, project, routes and (optionally) container health. */
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy registry: every concern that varies by distribution (secrets
|
|
3
|
+
* backend today; TLS and others next) is a strategy, and Den's built-ins
|
|
4
|
+
* register here exactly as a plugin would. An entry is
|
|
5
|
+
* `{ id, kind, detect(), capabilities }`:
|
|
6
|
+
*
|
|
7
|
+
* - `kind` names the concern ("secrets"); ids are unique per kind;
|
|
8
|
+
* - `detect()` is cheap and side-effect free: it never prompts, never
|
|
9
|
+
* spawns a CLI that could prompt, and never prints a secret. It says
|
|
10
|
+
* whether the strategy can work on this machine, and why not;
|
|
11
|
+
* - `capabilities` is the kind's own interface (a factory, optional setup,
|
|
12
|
+
* optional env injection, ...).
|
|
13
|
+
*
|
|
14
|
+
* No plugin loading happens here: a future plugin kind calls
|
|
15
|
+
* registerStrategy() with its entry.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
|
|
20
|
+
export interface DetectResult {
|
|
21
|
+
available: boolean;
|
|
22
|
+
/** Why the strategy is unavailable, or a note (e.g. "not configured"). */
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StrategyEntry<K extends string = string, C = unknown> {
|
|
27
|
+
id: string;
|
|
28
|
+
kind: K;
|
|
29
|
+
detect(env?: NodeJS.ProcessEnv): DetectResult;
|
|
30
|
+
capabilities: C;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registries = new Map<string, Map<string, StrategyEntry>>();
|
|
34
|
+
|
|
35
|
+
/** Register (or replace) a strategy under its kind. */
|
|
36
|
+
export function registerStrategy<K extends string, C>(
|
|
37
|
+
entry: StrategyEntry<K, C>,
|
|
38
|
+
): void {
|
|
39
|
+
let byId = registries.get(entry.kind);
|
|
40
|
+
if (!byId) {
|
|
41
|
+
byId = new Map();
|
|
42
|
+
registries.set(entry.kind, byId);
|
|
43
|
+
}
|
|
44
|
+
byId.set(entry.id, entry as StrategyEntry);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The strategy `id` of `kind`, or undefined. */
|
|
48
|
+
export function getStrategy<K extends string, C>(
|
|
49
|
+
kind: K,
|
|
50
|
+
id: string,
|
|
51
|
+
): StrategyEntry<K, C> | undefined {
|
|
52
|
+
return registries.get(kind)?.get(id) as StrategyEntry<K, C> | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every kind with a registered strategy, in registration order. */
|
|
56
|
+
export function strategyKinds(): string[] {
|
|
57
|
+
return [...registries.keys()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every registered id of `kind`, in registration order. */
|
|
61
|
+
export function strategyIds(kind: string): string[] {
|
|
62
|
+
return [...(registries.get(kind)?.keys() ?? [])];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True when an executable named `bin` is on `env.PATH`. A file check only:
|
|
67
|
+
* nothing is run, so it can never prompt (Touch ID, a login) or print.
|
|
68
|
+
*/
|
|
69
|
+
export function onPath(
|
|
70
|
+
bin: string,
|
|
71
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
72
|
+
): boolean {
|
|
73
|
+
const exts =
|
|
74
|
+
process.platform === "win32"
|
|
75
|
+
? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
|
|
76
|
+
: [""];
|
|
77
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
78
|
+
if (!dir) continue;
|
|
79
|
+
for (const ext of exts) {
|
|
80
|
+
try {
|
|
81
|
+
fs.accessSync(path.join(dir, bin + ext), fs.constants.X_OK);
|
|
82
|
+
if (fs.statSync(path.join(dir, bin + ext)).isFile()) return true;
|
|
83
|
+
} catch {
|
|
84
|
+
/* not here */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|