@monster-orange/den 0.2.8 → 0.4.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 +1731 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +730 -6
- 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 +12 -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/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +173 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- 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 +257 -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/engine.ts +23 -0
- 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/environment-manager/workspaces.ts +19 -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/secret-env.ts +70 -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/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- 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/agents-command.ts +812 -0
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +260 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +18 -5
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +580 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/sessions-command.ts +718 -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 +145 -20
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +94 -12
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +35 -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 +539 -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 +37 -3
- 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 +48 -18
- 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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace's stack namespace: the suffix its stacks' Compose projects run
|
|
3
|
+
* under (`<stack>-<namespace>`), and so the names of their containers,
|
|
4
|
+
* volumes and networks.
|
|
5
|
+
*
|
|
6
|
+
* By default it is the workspace's own name (`default`: `main`, see
|
|
7
|
+
* stackWorkspace). A workspace can set `stack_namespace` to opt out: several
|
|
8
|
+
* workspaces may then share one namespace, and a stack keeps its data when
|
|
9
|
+
* its repo moves between workspaces. First match wins:
|
|
10
|
+
*
|
|
11
|
+
* 1. `stack_namespace:` on the workspace in workspaces.yml (per machine)
|
|
12
|
+
* 2. `stack_namespace:` on the same-named blueprint workspace
|
|
13
|
+
* 3. stackWorkspace(name)
|
|
14
|
+
*
|
|
15
|
+
* Every stack code path turns a den workspace into its suffix through
|
|
16
|
+
* resolveStackWorkspaces (status.ts), which calls this; DEN_STACK_WORKSPACE
|
|
17
|
+
* still wins there, for one process.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
checkStackNamespace,
|
|
21
|
+
readBlueprintStackNamespaces,
|
|
22
|
+
readDenConfig,
|
|
23
|
+
type DenConfig,
|
|
24
|
+
} from "../den-config.js";
|
|
25
|
+
import { readWorkspacesFile } from "../environment-manager/workspaces.js";
|
|
26
|
+
import { cachedRead } from "../read-cache.js";
|
|
27
|
+
import type { WorkspacesFile } from "../types/index.js";
|
|
28
|
+
import { stackWorkspace } from "./render.js";
|
|
29
|
+
|
|
30
|
+
/** Which step gave the namespace. */
|
|
31
|
+
export type StackNamespaceSource = "workspace" | "blueprint" | "default";
|
|
32
|
+
|
|
33
|
+
export interface StackNamespace {
|
|
34
|
+
workspace: string;
|
|
35
|
+
/** The Compose project suffix. */
|
|
36
|
+
namespace: string;
|
|
37
|
+
source: StackNamespaceSource;
|
|
38
|
+
/** What it would be with no `stack_namespace` anywhere. */
|
|
39
|
+
defaultNamespace: string;
|
|
40
|
+
/** workspaces.yml overriding a different blueprint value, as a sentence. */
|
|
41
|
+
warnings: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StackNamespaceContext {
|
|
45
|
+
file: Pick<WorkspacesFile, "workspaces">;
|
|
46
|
+
/** Blueprint `stack_namespace` by workspace name. */
|
|
47
|
+
blueprint: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* workspaces.yml and the blueprint's namespaces, as they are now. A
|
|
52
|
+
* workspaces file that cannot be read sets no namespace (its other readers
|
|
53
|
+
* report it), and so does an unreadable org config; a bad
|
|
54
|
+
* `stack_namespace` value still throws.
|
|
55
|
+
*/
|
|
56
|
+
export function readStackNamespaceContext(): StackNamespaceContext {
|
|
57
|
+
return cachedRead("stack-namespace-context", () => {
|
|
58
|
+
let file: StackNamespaceContext["file"] = { workspaces: {} };
|
|
59
|
+
try {
|
|
60
|
+
file = readWorkspacesFile() ?? file;
|
|
61
|
+
} catch {
|
|
62
|
+
/* unreadable: no workspace sets one */
|
|
63
|
+
}
|
|
64
|
+
let config: DenConfig | null = null;
|
|
65
|
+
try {
|
|
66
|
+
config = readDenConfig();
|
|
67
|
+
} catch {
|
|
68
|
+
/* unreadable: no blueprint sets one */
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
file,
|
|
72
|
+
blueprint: config ? readBlueprintStackNamespaces(config) : {},
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve one workspace's stack namespace. A malformed `stack_namespace`
|
|
79
|
+
* (workspaces.yml or blueprint) throws, naming the key: falling back to
|
|
80
|
+
* another name would run the stacks against other volumes.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveStackNamespace(
|
|
83
|
+
name: string,
|
|
84
|
+
ctx: StackNamespaceContext = readStackNamespaceContext(),
|
|
85
|
+
): StackNamespace {
|
|
86
|
+
const defaultNamespace = stackWorkspace(name);
|
|
87
|
+
const own = checkStackNamespace(
|
|
88
|
+
`workspaces.${name}.stack_namespace (config/local/workspaces.yml)`,
|
|
89
|
+
ctx.file.workspaces?.[name]?.stack_namespace,
|
|
90
|
+
);
|
|
91
|
+
const bp = ctx.blueprint[name];
|
|
92
|
+
const warnings: string[] = [];
|
|
93
|
+
if (own) {
|
|
94
|
+
if (bp && bp !== own)
|
|
95
|
+
warnings.push(
|
|
96
|
+
`workspace ${name}: stack_namespace ${own} in workspaces.yml overrides the blueprint's stack_namespace ${bp}`,
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
workspace: name,
|
|
100
|
+
namespace: own,
|
|
101
|
+
source: "workspace",
|
|
102
|
+
defaultNamespace,
|
|
103
|
+
warnings,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (bp)
|
|
107
|
+
return {
|
|
108
|
+
workspace: name,
|
|
109
|
+
namespace: bp,
|
|
110
|
+
source: "blueprint",
|
|
111
|
+
defaultNamespace,
|
|
112
|
+
warnings,
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
workspace: name,
|
|
116
|
+
namespace: defaultNamespace,
|
|
117
|
+
source: "default",
|
|
118
|
+
defaultNamespace,
|
|
119
|
+
warnings,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One workspace's namespace as the lists show it: resolved, or the error. */
|
|
124
|
+
export interface StackNamespaceRow {
|
|
125
|
+
workspace: string;
|
|
126
|
+
/** The resolved namespace; null when its config is invalid. */
|
|
127
|
+
stack_namespace: string | null;
|
|
128
|
+
source: StackNamespaceSource | null;
|
|
129
|
+
/** The suffix with no `stack_namespace` anywhere. */
|
|
130
|
+
default: string;
|
|
131
|
+
/** True when the namespace is not the default suffix. */
|
|
132
|
+
differs: boolean;
|
|
133
|
+
warnings: string[];
|
|
134
|
+
error?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every named workspace's stack namespace, for `workspace list` and
|
|
139
|
+
* `/api/workspaces`. A bad value is that row's error, never the list's.
|
|
140
|
+
*/
|
|
141
|
+
export function stackNamespaceRows(
|
|
142
|
+
names: string[],
|
|
143
|
+
ctx?: StackNamespaceContext,
|
|
144
|
+
): StackNamespaceRow[] {
|
|
145
|
+
let c: StackNamespaceContext | null = ctx ?? null;
|
|
146
|
+
let ctxError: string | null = null;
|
|
147
|
+
if (!c)
|
|
148
|
+
try {
|
|
149
|
+
c = readStackNamespaceContext();
|
|
150
|
+
} catch (e) {
|
|
151
|
+
ctxError = (e as Error).message;
|
|
152
|
+
}
|
|
153
|
+
return names.map((workspace) => {
|
|
154
|
+
const fallback = stackWorkspace(workspace);
|
|
155
|
+
try {
|
|
156
|
+
if (!c) throw new Error(ctxError ?? "unreadable");
|
|
157
|
+
const ns = resolveStackNamespace(workspace, c);
|
|
158
|
+
return {
|
|
159
|
+
workspace,
|
|
160
|
+
stack_namespace: ns.namespace,
|
|
161
|
+
source: ns.source,
|
|
162
|
+
default: ns.defaultNamespace,
|
|
163
|
+
differs: ns.namespace !== ns.defaultNamespace,
|
|
164
|
+
warnings: ns.warnings,
|
|
165
|
+
};
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return {
|
|
168
|
+
workspace,
|
|
169
|
+
stack_namespace: null,
|
|
170
|
+
source: null,
|
|
171
|
+
default: fallback,
|
|
172
|
+
differs: false,
|
|
173
|
+
warnings: [],
|
|
174
|
+
error: (e as Error).message,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -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");
|