@monster-orange/den 0.0.0 → 0.2.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 +204 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +68 -0
- package/docs/distributions.md +499 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +101 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +403 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +124 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +757 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +688 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +635 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +108 -0
- package/src/shared/environment-manager/log-tail.ts +213 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +335 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1603 -0
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +326 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +278 -0
- package/src/shared/manifest/schema.ts +313 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +200 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +190 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +118 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +701 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +148 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +81 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +570 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3742 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +117 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1319 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +105 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3350 -0
- package/src/ui/server/log-history.ts +453 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +496 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +1373 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +5344 -0
- package/src/ui/server/public/logs.html +1496 -0
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1103 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +2185 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands den supplies for a repo whose `.den.yml` has a `compose:`
|
|
3
|
+
* section: the stack runs behind the edge with no host ports. A command the
|
|
4
|
+
* repo declares itself (services.<svc>.commands.local) always wins.
|
|
5
|
+
*/
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { getDistRoot } from "../paths.js";
|
|
8
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
9
|
+
import type { RawManifest } from "../types/index.js";
|
|
10
|
+
import { manifestStackName } from "./config.js";
|
|
11
|
+
import { containerName, parseTarget, projectName } from "./render.js";
|
|
12
|
+
|
|
13
|
+
import { product } from "../product.js";
|
|
14
|
+
type CommandDef = string | { cmd: string; detachable?: boolean };
|
|
15
|
+
|
|
16
|
+
/** Whether `subsystem` is the repo's primary service (the one the stack belongs to). */
|
|
17
|
+
function isPrimary(manifest: RawManifest, subsystem: string): boolean {
|
|
18
|
+
const keys = Object.keys(manifest.services ?? {});
|
|
19
|
+
if (manifest.entrypoint) return manifest.entrypoint === subsystem;
|
|
20
|
+
return keys.length <= 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function denBin(): string {
|
|
24
|
+
return JSON.stringify(path.join(getDistRoot(), "bin", `${product().cli}`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The synthesized stack commands, or {} when the repo isn't a compose stack. */
|
|
28
|
+
export function composeCommands(
|
|
29
|
+
manifest: RawManifest,
|
|
30
|
+
subsystem: string,
|
|
31
|
+
repoName: string,
|
|
32
|
+
workspace: string,
|
|
33
|
+
): Record<string, CommandDef> {
|
|
34
|
+
if (!manifest.compose || !isPrimary(manifest, subsystem)) return {};
|
|
35
|
+
// `docker compose up -d` returns once the containers are started, so these
|
|
36
|
+
// finish on their own: never daemonize them.
|
|
37
|
+
const stack = (sub: string) => ({
|
|
38
|
+
cmd: `${denBin()} stack ${sub} ${manifestStackName(manifest, repoName)} -w ${workspace}`,
|
|
39
|
+
detachable: false,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
up: stack("up"),
|
|
43
|
+
down: stack("down"),
|
|
44
|
+
restart: stack("restart"),
|
|
45
|
+
logs: stack("logs"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Shell health check for a compose stack. The entry service is healthy when
|
|
51
|
+
* every container is running and healthy; any other manifest service names a
|
|
52
|
+
* compose service and is checked on its own (`--service`).
|
|
53
|
+
*/
|
|
54
|
+
export function composeHealthCmd(
|
|
55
|
+
manifest: RawManifest,
|
|
56
|
+
subsystem: string,
|
|
57
|
+
repoName: string,
|
|
58
|
+
workspace: string,
|
|
59
|
+
): string | null {
|
|
60
|
+
if (!manifest.compose) return null;
|
|
61
|
+
const base = `${denBin()} stack health ${manifestStackName(manifest, repoName)} -w ${workspace}`;
|
|
62
|
+
return isPrimary(manifest, subsystem)
|
|
63
|
+
? base
|
|
64
|
+
: `${base} --service ${JSON.stringify(subsystem)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The containers a compose-stack service runs as, best match first ([] when
|
|
69
|
+
* the repo isn't a compose stack). Any other manifest service names a compose
|
|
70
|
+
* service; the primary one also falls back to its routes' target services.
|
|
71
|
+
* `stackWorkspace` is the Compose project suffix (see resolveStackWorkspaces).
|
|
72
|
+
*/
|
|
73
|
+
export function composeServiceContainers(
|
|
74
|
+
manifest: RawManifest,
|
|
75
|
+
subsystem: string,
|
|
76
|
+
repoName: string,
|
|
77
|
+
stackWorkspace: string,
|
|
78
|
+
): string[] {
|
|
79
|
+
if (!manifest.compose) return [];
|
|
80
|
+
const project = projectName(
|
|
81
|
+
manifestStackName(manifest, repoName),
|
|
82
|
+
stackWorkspace,
|
|
83
|
+
);
|
|
84
|
+
const services = [subsystem];
|
|
85
|
+
if (isPrimary(manifest, subsystem))
|
|
86
|
+
for (const target of Object.values(manifest.compose.routes ?? {})) {
|
|
87
|
+
try {
|
|
88
|
+
services.push(parseTarget(target).service);
|
|
89
|
+
} catch {
|
|
90
|
+
/* a malformed route is reported by the stack commands */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...new Set(services)].map((svc) => containerName(project, svc));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Declared local commands layered over the synthesized compose ones. */
|
|
97
|
+
export function effectiveLocalCommands(
|
|
98
|
+
manifest: RawManifest,
|
|
99
|
+
subsystem: string,
|
|
100
|
+
repoName: string,
|
|
101
|
+
workspace: string,
|
|
102
|
+
): Record<string, CommandDef> {
|
|
103
|
+
return {
|
|
104
|
+
...composeCommands(manifest, subsystem, repoName, workspace),
|
|
105
|
+
...(manifest.services?.[subsystem]?.commands?.local ?? {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Effective local commands for a resolved service, read from its manifest. */
|
|
110
|
+
export function localCommandsFor(
|
|
111
|
+
svc: { repoPath: string; repoName: string; subsystem: string },
|
|
112
|
+
workspace: string,
|
|
113
|
+
): Record<string, CommandDef> {
|
|
114
|
+
const m = readManifest(svc.repoPath);
|
|
115
|
+
return m
|
|
116
|
+
? effectiveLocalCommands(m, svc.subsystem, svc.repoName, workspace)
|
|
117
|
+
: {};
|
|
118
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for stacks: project names, container names, the compose
|
|
3
|
+
* override, the link networks and the edge routes. No I/O.
|
|
4
|
+
*/
|
|
5
|
+
import * as yaml from "yaml";
|
|
6
|
+
import { edgeDomains, routeHost } from "../edge/render.js";
|
|
7
|
+
import type { EdgeConfig } from "../edge/types.js";
|
|
8
|
+
import type { ResolvedProject, StackConfig } from "./types.js";
|
|
9
|
+
|
|
10
|
+
import { product } from "../product.js";
|
|
11
|
+
const NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
12
|
+
|
|
13
|
+
export function assertStackName(n: string): void {
|
|
14
|
+
if (!NAME_RE.test(n))
|
|
15
|
+
throw new Error(
|
|
16
|
+
`stack name "${n}" must be lowercase letters, digits and dashes`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function projectName(stack: string, workspace: string): string {
|
|
21
|
+
return `${stack}-${workspace}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Container name for a stack service. Always Compose's per-project default:
|
|
26
|
+
* the override resets any pinned `container_name`, which would otherwise
|
|
27
|
+
* collide when the same repo runs in two workspaces.
|
|
28
|
+
*/
|
|
29
|
+
export function containerName(project: string, service: string): string {
|
|
30
|
+
return `${project}-${service}-1`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function linkNetwork(
|
|
34
|
+
consumer: string,
|
|
35
|
+
provider: string,
|
|
36
|
+
ws: string,
|
|
37
|
+
): string {
|
|
38
|
+
return `${consumer}-${ws}--${provider}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseTarget(target: string): { service: string; port: number } {
|
|
42
|
+
const m = /^([a-zA-Z0-9][\w.-]*):(\d{1,5})$/.exec(target);
|
|
43
|
+
if (!m) throw new Error(`route target "${target}" must be <service>:<port>`);
|
|
44
|
+
return { service: m[1], port: Number(m[2]) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Expand `{stack.service}` placeholders to container names. */
|
|
48
|
+
export function expandEnv(
|
|
49
|
+
env: Record<string, string>,
|
|
50
|
+
ws: string,
|
|
51
|
+
known: Record<string, StackConfig>,
|
|
52
|
+
): Record<string, string> {
|
|
53
|
+
const out: Record<string, string> = {};
|
|
54
|
+
for (const [k, v] of Object.entries(env))
|
|
55
|
+
out[k] = v.replace(
|
|
56
|
+
/\{([a-z0-9-]+)\.([\w.-]+)\}/g,
|
|
57
|
+
(_m, s: string, svc: string) => {
|
|
58
|
+
if (!known[s]) throw new Error(`env ${k} names unknown stack "${s}"`);
|
|
59
|
+
return containerName(projectName(s, ws), svc);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Providers named in `{stack.service}` placeholders → their services. */
|
|
66
|
+
export function providerServices(stack: StackConfig): Record<string, string[]> {
|
|
67
|
+
const out: Record<string, string[]> = {};
|
|
68
|
+
for (const v of Object.values(stack.env))
|
|
69
|
+
for (const m of v.matchAll(/\{([a-z0-9-]+)\.([\w.-]+)\}/g))
|
|
70
|
+
(out[m[1]] ??= []).includes(m[2]) || out[m[1]].push(m[2]);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Start order: every stack after the stacks it uses. Throws on cycles. */
|
|
75
|
+
export function startOrder(
|
|
76
|
+
names: string[],
|
|
77
|
+
all: Record<string, StackConfig>,
|
|
78
|
+
): string[] {
|
|
79
|
+
const order: string[] = [];
|
|
80
|
+
const state = new Map<string, "visiting" | "done">();
|
|
81
|
+
const visit = (n: string, trail: string[]) => {
|
|
82
|
+
if (!all[n]) throw new Error(`unknown stack "${n}"`);
|
|
83
|
+
if (state.get(n) === "done") return;
|
|
84
|
+
if (state.get(n) === "visiting")
|
|
85
|
+
throw new Error(
|
|
86
|
+
`stacks use each other in a cycle: ${[...trail, n].join(" → ")}`,
|
|
87
|
+
);
|
|
88
|
+
state.set(n, "visiting");
|
|
89
|
+
for (const u of all[n].uses) visit(u, [...trail, n]);
|
|
90
|
+
state.set(n, "done");
|
|
91
|
+
order.push(n);
|
|
92
|
+
};
|
|
93
|
+
names.forEach((n) => visit(n, []));
|
|
94
|
+
return order;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Services that refer to a provider: their resolved environment names one of
|
|
99
|
+
* the provider's container names. Only these join the link network, so a
|
|
100
|
+
* consumer's postgres/redis never become resolvable from the provider.
|
|
101
|
+
*/
|
|
102
|
+
export function linkedServices(
|
|
103
|
+
stack: StackConfig,
|
|
104
|
+
provider: string,
|
|
105
|
+
resolved: ResolvedProject,
|
|
106
|
+
ws: string,
|
|
107
|
+
): string[] {
|
|
108
|
+
const names = (providerServices(stack)[provider] ?? []).map((svc) =>
|
|
109
|
+
containerName(projectName(provider, ws), svc),
|
|
110
|
+
);
|
|
111
|
+
return Object.entries(resolved.services)
|
|
112
|
+
.filter(([, def]) =>
|
|
113
|
+
Object.values(def.environment ?? {}).some(
|
|
114
|
+
(v) => typeof v === "string" && names.some((n) => v.includes(n)),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
.map(([svc]) => svc)
|
|
118
|
+
.sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The compose override: drop every published host port and pinned container
|
|
123
|
+
* name, and put the services that refer to a provider on its link network.
|
|
124
|
+
* Written as text because `!reset` is a Compose tag, not plain YAML.
|
|
125
|
+
*/
|
|
126
|
+
export function renderOverride(
|
|
127
|
+
stack: StackConfig,
|
|
128
|
+
resolved: ResolvedProject,
|
|
129
|
+
ws: string,
|
|
130
|
+
): string {
|
|
131
|
+
const links = stack.uses.map((p) => ({
|
|
132
|
+
net: linkNetwork(stack.name, p, ws),
|
|
133
|
+
services: linkedServices(stack, p, resolved, ws),
|
|
134
|
+
}));
|
|
135
|
+
const unknown = Object.keys(stack.serviceEnv ?? {}).filter(
|
|
136
|
+
(s) => !(s in resolved.services),
|
|
137
|
+
);
|
|
138
|
+
if (unknown.length)
|
|
139
|
+
throw new Error(
|
|
140
|
+
`stack "${stack.name}": service_env names "${unknown.join('", "')}", ` +
|
|
141
|
+
`which the compose project does not have (${Object.keys(resolved.services).sort().join(", ")})`,
|
|
142
|
+
);
|
|
143
|
+
const lines = [
|
|
144
|
+
`# Generated by \`${product().cli} stack up\`: no host ports (reach services via the`,
|
|
145
|
+
"# edge) and no pinned container names (each workspace is its own project).",
|
|
146
|
+
"services:",
|
|
147
|
+
];
|
|
148
|
+
for (const [svc, def] of Object.entries(resolved.services).sort()) {
|
|
149
|
+
const body: string[] = [];
|
|
150
|
+
if (def.ports && def.ports.length) body.push(" ports: !reset []");
|
|
151
|
+
if (def.container_name) body.push(" container_name: !reset null");
|
|
152
|
+
const check = stack.healthchecks?.[svc];
|
|
153
|
+
if (check)
|
|
154
|
+
body.push(" healthcheck:", ` test: ${JSON.stringify(check)}`);
|
|
155
|
+
const vars = stack.serviceEnv?.[svc];
|
|
156
|
+
if (vars && Object.keys(vars).length) {
|
|
157
|
+
// Compose merges `environment` by key across files, so these win.
|
|
158
|
+
body.push(" environment:");
|
|
159
|
+
for (const [k, v] of Object.entries(vars).sort())
|
|
160
|
+
body.push(` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
|
|
161
|
+
}
|
|
162
|
+
const mine = links
|
|
163
|
+
.filter((l) => l.services.includes(svc))
|
|
164
|
+
.map((l) => l.net);
|
|
165
|
+
if (mine.length) {
|
|
166
|
+
// Compose merges service networks by key, so listing the base ones keeps them.
|
|
167
|
+
const base = Object.keys(def.networks ?? { default: null });
|
|
168
|
+
body.push(" networks:");
|
|
169
|
+
for (const n of [...base, ...mine])
|
|
170
|
+
body.push(` ${JSON.stringify(n)}: {}`);
|
|
171
|
+
}
|
|
172
|
+
if (body.length) lines.push(` ${JSON.stringify(svc)}:`, ...body);
|
|
173
|
+
}
|
|
174
|
+
if (!lines.some((l) => /^ {2}"/.test(l))) lines.push(" {}");
|
|
175
|
+
const used = links.filter((l) => l.services.length).map((l) => l.net);
|
|
176
|
+
if (used.length) {
|
|
177
|
+
lines.push("networks:");
|
|
178
|
+
for (const n of used)
|
|
179
|
+
lines.push(
|
|
180
|
+
` ${JSON.stringify(n)}:`,
|
|
181
|
+
` name: ${JSON.stringify(n)}`,
|
|
182
|
+
" external: true",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return lines.join("\n") + "\n";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Docker networks the edge must join to reach each routed service. */
|
|
189
|
+
export function routedNetworks(
|
|
190
|
+
stack: StackConfig,
|
|
191
|
+
resolved: ResolvedProject,
|
|
192
|
+
): string[] {
|
|
193
|
+
const nets = new Set<string>();
|
|
194
|
+
for (const target of Object.values(stack.routes)) {
|
|
195
|
+
const { service } = parseTarget(target);
|
|
196
|
+
const def = resolved.services[service];
|
|
197
|
+
if (!def)
|
|
198
|
+
throw new Error(
|
|
199
|
+
`stack ${stack.name}: route target service "${service}" not in compose`,
|
|
200
|
+
);
|
|
201
|
+
const first = Object.keys(def.networks ?? { default: null })[0];
|
|
202
|
+
nets.add(resolved.networks?.[first]?.name ?? `${resolved.name}_${first}`);
|
|
203
|
+
}
|
|
204
|
+
return [...nets].sort();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Edge dynamic config for one stack: `<route>.<stack>.<domain>` → container. */
|
|
208
|
+
export function renderStackRoutes(
|
|
209
|
+
edge: EdgeConfig,
|
|
210
|
+
stack: StackConfig,
|
|
211
|
+
resolved: ResolvedProject,
|
|
212
|
+
): string {
|
|
213
|
+
const routers: Record<string, unknown> = {};
|
|
214
|
+
const services: Record<string, unknown> = {};
|
|
215
|
+
for (const [route, target] of Object.entries(stack.routes).sort()) {
|
|
216
|
+
if (!NAME_RE.test(route))
|
|
217
|
+
throw new Error(
|
|
218
|
+
`route "${route}" must be lowercase letters, digits and dashes`,
|
|
219
|
+
);
|
|
220
|
+
const { service, port } = parseTarget(target);
|
|
221
|
+
const key = `stack-${stack.name}-${route}`;
|
|
222
|
+
routers[key] = {
|
|
223
|
+
entryPoints: ["websecure"],
|
|
224
|
+
rule: edgeDomains(edge)
|
|
225
|
+
.map((d) => `Host(\`${routeHost(`${route}.${stack.name}`, d)}\`)`)
|
|
226
|
+
.join(" || "),
|
|
227
|
+
service: key,
|
|
228
|
+
tls: {},
|
|
229
|
+
};
|
|
230
|
+
services[key] = {
|
|
231
|
+
loadBalancer: {
|
|
232
|
+
servers: [
|
|
233
|
+
{
|
|
234
|
+
url: `http://${containerName(resolved.name, service)}:${port}`,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return (
|
|
241
|
+
`# Generated by \`${product().cli} stack up ${stack.name}\`.\n` +
|
|
242
|
+
yaml.stringify({ http: { routers, services } })
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compose-project suffix for a den workspace: its own name, except that
|
|
248
|
+
* the `default` workspace keeps the original `main` projects (and so their
|
|
249
|
+
* volumes and data).
|
|
250
|
+
*/
|
|
251
|
+
export function stackWorkspace(denWorkspace: string): string {
|
|
252
|
+
return denWorkspace === "default" ? "main" : denWorkspace;
|
|
253
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/** Side effects for stacks: docker compose, link networks, edge routes. */
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { getReposRoot } from "../den-config.js";
|
|
7
|
+
import { edgeNames } from "../edge/render.js";
|
|
8
|
+
import type { EdgeConfig } from "../edge/types.js";
|
|
9
|
+
import {
|
|
10
|
+
containerName,
|
|
11
|
+
expandEnv,
|
|
12
|
+
linkNetwork,
|
|
13
|
+
projectName,
|
|
14
|
+
providerServices,
|
|
15
|
+
renderOverride,
|
|
16
|
+
renderStackRoutes,
|
|
17
|
+
routedNetworks,
|
|
18
|
+
} from "./render.js";
|
|
19
|
+
import type { ResolvedProject, StackConfig } from "./types.js";
|
|
20
|
+
|
|
21
|
+
function run(
|
|
22
|
+
cmd: string,
|
|
23
|
+
args: string[],
|
|
24
|
+
opts: { cwd?: string; env?: NodeJS.ProcessEnv; inherit?: boolean } = {},
|
|
25
|
+
) {
|
|
26
|
+
const r = spawnSync(cmd, args, {
|
|
27
|
+
cwd: opts.cwd,
|
|
28
|
+
encoding: "utf-8",
|
|
29
|
+
env: { ...process.env, ...opts.env },
|
|
30
|
+
stdio: opts.inherit ? "inherit" : "pipe",
|
|
31
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
if (r.error) throw new Error(`${cmd}: ${r.error.message}`);
|
|
34
|
+
return {
|
|
35
|
+
code: r.status ?? 1,
|
|
36
|
+
out: (r.stdout ?? "").trim(),
|
|
37
|
+
err: (r.stderr ?? "").trim(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function must(
|
|
42
|
+
cmd: string,
|
|
43
|
+
args: string[],
|
|
44
|
+
opts: Parameters<typeof run>[2] = {},
|
|
45
|
+
): string {
|
|
46
|
+
const r = run(cmd, args, opts);
|
|
47
|
+
if (r.code !== 0)
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${cmd} ${args.slice(0, 6).join(" ")}… failed:\n${r.err || r.out}`,
|
|
50
|
+
);
|
|
51
|
+
return r.out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StackContext {
|
|
55
|
+
edge: EdgeConfig;
|
|
56
|
+
stacks: Record<string, StackConfig>;
|
|
57
|
+
workspace: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stackDir(ctx: StackContext, s: StackConfig): string {
|
|
61
|
+
return path.join(
|
|
62
|
+
ctx.edge.stateDir,
|
|
63
|
+
"stacks",
|
|
64
|
+
projectName(s.name, ctx.workspace),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function repoDir(s: StackConfig): string {
|
|
69
|
+
const dir = path.join(
|
|
70
|
+
s.repoPath ?? path.join(getReposRoot(), s.repo),
|
|
71
|
+
s.dir ?? "",
|
|
72
|
+
);
|
|
73
|
+
if (!fs.existsSync(dir))
|
|
74
|
+
throw new Error(
|
|
75
|
+
`stack ${s.name}: ${dir} does not exist (clone ${s.repo} first)`,
|
|
76
|
+
);
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Generated local-only secrets, created once per stack + workspace. */
|
|
81
|
+
function generatedEnv(
|
|
82
|
+
ctx: StackContext,
|
|
83
|
+
s: StackConfig,
|
|
84
|
+
): Record<string, string> {
|
|
85
|
+
const file = path.join(stackDir(ctx, s), "generated.env");
|
|
86
|
+
const have: Record<string, string> = {};
|
|
87
|
+
if (fs.existsSync(file))
|
|
88
|
+
for (const line of fs.readFileSync(file, "utf-8").split("\n")) {
|
|
89
|
+
const i = line.indexOf("=");
|
|
90
|
+
if (i > 0) have[line.slice(0, i)] = line.slice(i + 1);
|
|
91
|
+
}
|
|
92
|
+
let changed = false;
|
|
93
|
+
for (const k of s.generate)
|
|
94
|
+
if (!have[k]) {
|
|
95
|
+
have[k] = randomBytes(32).toString("base64url");
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
if (changed) {
|
|
99
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
100
|
+
fs.writeFileSync(
|
|
101
|
+
file,
|
|
102
|
+
Object.entries(have)
|
|
103
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
104
|
+
.join("\n") + "\n",
|
|
105
|
+
{ mode: 0o600 },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return have;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function composeEnv(ctx: StackContext, s: StackConfig): NodeJS.ProcessEnv {
|
|
112
|
+
return {
|
|
113
|
+
...generatedEnv(ctx, s),
|
|
114
|
+
...expandEnv(s.env, ctx.workspace, ctx.stacks),
|
|
115
|
+
COMPOSE_PROJECT_NAME: projectName(s.name, ctx.workspace),
|
|
116
|
+
...(s.profiles.length ? { COMPOSE_PROFILES: s.profiles.join(",") } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function baseArgs(ctx: StackContext, s: StackConfig): string[] {
|
|
121
|
+
return [
|
|
122
|
+
"compose",
|
|
123
|
+
"-p",
|
|
124
|
+
projectName(s.name, ctx.workspace),
|
|
125
|
+
...s.files.flatMap((f) => ["-f", f]),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolve(ctx: StackContext, s: StackConfig): ResolvedProject {
|
|
130
|
+
const out = must(
|
|
131
|
+
"docker",
|
|
132
|
+
[...baseArgs(ctx, s), "config", "--format", "json"],
|
|
133
|
+
{
|
|
134
|
+
cwd: repoDir(s),
|
|
135
|
+
env: composeEnv(ctx, s),
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
return JSON.parse(out) as ResolvedProject;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function overrideArgs(ctx: StackContext, s: StackConfig): string[] {
|
|
142
|
+
return [
|
|
143
|
+
...baseArgs(ctx, s),
|
|
144
|
+
"-f",
|
|
145
|
+
path.join(stackDir(ctx, s), "override.yml"),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function ensureNetwork(name: string): void {
|
|
150
|
+
if (run("docker", ["network", "inspect", name]).code !== 0)
|
|
151
|
+
must("docker", ["network", "create", name]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `docker network connect`, ignoring "already connected". Container-name alias only. */
|
|
155
|
+
function connect(network: string, container: string): void {
|
|
156
|
+
const r = run("docker", ["network", "connect", network, container]);
|
|
157
|
+
if (r.code !== 0 && !/already exists|already connected/i.test(r.err))
|
|
158
|
+
throw new Error(`docker network connect ${network} ${container}: ${r.err}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function running(container: string): boolean {
|
|
162
|
+
return (
|
|
163
|
+
run("docker", ["inspect", "-f", "{{.State.Running}}", container]).out ===
|
|
164
|
+
"true"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function stackUp(
|
|
169
|
+
ctx: StackContext,
|
|
170
|
+
s: StackConfig,
|
|
171
|
+
opts: { build?: boolean } = {},
|
|
172
|
+
) {
|
|
173
|
+
const resolved = resolve(ctx, s);
|
|
174
|
+
// Provider side of each link: its referenced services join a private network.
|
|
175
|
+
const needs = providerServices(s);
|
|
176
|
+
for (const p of s.uses) {
|
|
177
|
+
const net = linkNetwork(s.name, p, ctx.workspace);
|
|
178
|
+
ensureNetwork(net);
|
|
179
|
+
for (const svc of needs[p] ?? []) {
|
|
180
|
+
const c = containerName(projectName(p, ctx.workspace), svc);
|
|
181
|
+
if (!running(c))
|
|
182
|
+
throw new Error(
|
|
183
|
+
`stack ${s.name} needs ${p}'s ${svc} (${c}), which is not running`,
|
|
184
|
+
);
|
|
185
|
+
connect(net, c);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const dir = stackDir(ctx, s);
|
|
189
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
190
|
+
fs.writeFileSync(
|
|
191
|
+
path.join(dir, "override.yml"),
|
|
192
|
+
renderOverride(s, resolved, ctx.workspace),
|
|
193
|
+
);
|
|
194
|
+
must(
|
|
195
|
+
"docker",
|
|
196
|
+
[
|
|
197
|
+
...overrideArgs(ctx, s),
|
|
198
|
+
"up",
|
|
199
|
+
"-d",
|
|
200
|
+
...(opts.build ? ["--build"] : []),
|
|
201
|
+
"--remove-orphans",
|
|
202
|
+
],
|
|
203
|
+
{ cwd: repoDir(s), env: composeEnv(ctx, s), inherit: true },
|
|
204
|
+
);
|
|
205
|
+
// This stack as a provider: consumers already running keep their link
|
|
206
|
+
// network, but a restarted provider's new containers must rejoin it.
|
|
207
|
+
for (const c of Object.values(ctx.stacks)) {
|
|
208
|
+
if (!c.uses.includes(s.name)) continue;
|
|
209
|
+
const net = linkNetwork(c.name, s.name, ctx.workspace);
|
|
210
|
+
if (run("docker", ["network", "inspect", net]).code !== 0) continue;
|
|
211
|
+
for (const svc of providerServices(c)[s.name] ?? [])
|
|
212
|
+
connect(net, containerName(projectName(s.name, ctx.workspace), svc));
|
|
213
|
+
}
|
|
214
|
+
const edge = edgeNames(ctx.edge.org).container;
|
|
215
|
+
const nets = routedNetworks(s, resolved);
|
|
216
|
+
for (const n of nets) connect(n, edge);
|
|
217
|
+
fs.writeFileSync(
|
|
218
|
+
path.join(
|
|
219
|
+
ctx.edge.stateDir,
|
|
220
|
+
"dynamic",
|
|
221
|
+
`stack-${projectName(s.name, ctx.workspace)}.yml`,
|
|
222
|
+
),
|
|
223
|
+
renderStackRoutes(ctx.edge, s, resolved),
|
|
224
|
+
);
|
|
225
|
+
return { project: resolved.name, networks: nets };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function stackDown(ctx: StackContext, s: StackConfig): void {
|
|
229
|
+
fs.rmSync(
|
|
230
|
+
path.join(
|
|
231
|
+
ctx.edge.stateDir,
|
|
232
|
+
"dynamic",
|
|
233
|
+
`stack-${projectName(s.name, ctx.workspace)}.yml`,
|
|
234
|
+
),
|
|
235
|
+
{ force: true },
|
|
236
|
+
);
|
|
237
|
+
const edge = edgeNames(ctx.edge.org).container;
|
|
238
|
+
const dir = repoDir(s);
|
|
239
|
+
const override = path.join(stackDir(ctx, s), "override.yml");
|
|
240
|
+
const args = fs.existsSync(override)
|
|
241
|
+
? overrideArgs(ctx, s)
|
|
242
|
+
: baseArgs(ctx, s);
|
|
243
|
+
// The edge holds a connection to the project network; let go so `down` can remove it.
|
|
244
|
+
try {
|
|
245
|
+
for (const n of routedNetworks(s, resolve(ctx, s)))
|
|
246
|
+
run("docker", ["network", "disconnect", n, edge]);
|
|
247
|
+
} catch {
|
|
248
|
+
/* compose config failed — nothing to disconnect */
|
|
249
|
+
}
|
|
250
|
+
must("docker", [...args, "down"], {
|
|
251
|
+
cwd: dir,
|
|
252
|
+
env: composeEnv(ctx, s),
|
|
253
|
+
inherit: true,
|
|
254
|
+
});
|
|
255
|
+
const needs = providerServices(s);
|
|
256
|
+
for (const p of s.uses) {
|
|
257
|
+
const net = linkNetwork(s.name, p, ctx.workspace);
|
|
258
|
+
for (const svc of needs[p] ?? [])
|
|
259
|
+
run("docker", [
|
|
260
|
+
"network",
|
|
261
|
+
"disconnect",
|
|
262
|
+
net,
|
|
263
|
+
containerName(projectName(p, ctx.workspace), svc),
|
|
264
|
+
]);
|
|
265
|
+
run("docker", ["network", "rm", net]);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function stackPs(ctx: StackContext, s: StackConfig): string {
|
|
270
|
+
return run(
|
|
271
|
+
"docker",
|
|
272
|
+
[
|
|
273
|
+
...baseArgs(ctx, s),
|
|
274
|
+
"ps",
|
|
275
|
+
"--format",
|
|
276
|
+
"table {{.Service}}\t{{.State}}\t{{.Status}}",
|
|
277
|
+
],
|
|
278
|
+
{
|
|
279
|
+
cwd: repoDir(s),
|
|
280
|
+
env: composeEnv(ctx, s),
|
|
281
|
+
},
|
|
282
|
+
).out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function stackLogs(
|
|
286
|
+
ctx: StackContext,
|
|
287
|
+
s: StackConfig,
|
|
288
|
+
args: string[],
|
|
289
|
+
): number {
|
|
290
|
+
return run("docker", [...baseArgs(ctx, s), "logs", ...args], {
|
|
291
|
+
cwd: repoDir(s),
|
|
292
|
+
env: composeEnv(ctx, s),
|
|
293
|
+
inherit: true,
|
|
294
|
+
}).code;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Health of a running stack: ok when it has containers, all are running, and
|
|
299
|
+
* none reports a failing or pending Docker healthcheck.
|
|
300
|
+
*/
|
|
301
|
+
export function stackHealth(
|
|
302
|
+
ctx: StackContext,
|
|
303
|
+
s: StackConfig,
|
|
304
|
+
service?: string,
|
|
305
|
+
): { ok: boolean; detail: string } {
|
|
306
|
+
const project = projectName(s.name, ctx.workspace);
|
|
307
|
+
const r = run("docker", [
|
|
308
|
+
"ps",
|
|
309
|
+
"-a",
|
|
310
|
+
"--filter",
|
|
311
|
+
`label=com.docker.compose.project=${project}`,
|
|
312
|
+
...(service
|
|
313
|
+
? ["--filter", `label=com.docker.compose.service=${service}`]
|
|
314
|
+
: []),
|
|
315
|
+
"--format",
|
|
316
|
+
'{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
|
|
317
|
+
]);
|
|
318
|
+
if (r.code !== 0) return { ok: false, detail: r.err || "docker ps failed" };
|
|
319
|
+
const rows = r.out.split("\n").filter(Boolean);
|
|
320
|
+
if (!rows.length)
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
detail: `${project}${service ? `/${service}` : ""}: not running`,
|
|
324
|
+
};
|
|
325
|
+
const bad = rows.filter((row) => {
|
|
326
|
+
const [, state, status] = row.split("\t");
|
|
327
|
+
return (
|
|
328
|
+
state !== "running" || /\((unhealthy|health: starting)\)/.test(status)
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
return bad.length
|
|
332
|
+
? { ok: false, detail: bad.join("; ") }
|
|
333
|
+
: { ok: true, detail: `${rows.length} container(s) running` };
|
|
334
|
+
}
|