@monster-orange/den 0.0.0 → 0.1.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 +67 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +61 -0
- package/docs/distributions.md +256 -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 +99 -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 +406 -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 +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -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/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 +566 -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/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 +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -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-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -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/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -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 +178 -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/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 +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -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 +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -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 +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -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/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 +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -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/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -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 +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -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/workspace-routes.ts +2154 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stack = one repo's Docker Compose project, run behind the edge.
|
|
3
|
+
*
|
|
4
|
+
* stacks:
|
|
5
|
+
* webapp:
|
|
6
|
+
* repo: sample-webapp
|
|
7
|
+
* files: [docker-compose.yml] # relative to repo/dir
|
|
8
|
+
* routes: { app: frontend:80 } # https://app.webapp.<domain>
|
|
9
|
+
* generate: [SECRET_KEY] # random once, kept in local state
|
|
10
|
+
* uses: [ragstack] # start first + private link network
|
|
11
|
+
* env: # compose ${VAR} substitution
|
|
12
|
+
* RAG_PROXY_TARGET: "http://{ragstack.ragstack-server}:80"
|
|
13
|
+
*
|
|
14
|
+
* `{stack.service}` expands to that service's container name.
|
|
15
|
+
*/
|
|
16
|
+
export interface StackConfig {
|
|
17
|
+
name: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
/** Absolute checkout path; when unset, `<repos_root>/<repo>`. */
|
|
20
|
+
repoPath?: string;
|
|
21
|
+
/** Subdirectory of the repo holding the compose files. */
|
|
22
|
+
dir?: string;
|
|
23
|
+
files: string[];
|
|
24
|
+
/** `<route>` → `<service>:<container port>`. */
|
|
25
|
+
routes: Record<string, string>;
|
|
26
|
+
generate: string[];
|
|
27
|
+
uses: string[];
|
|
28
|
+
env: Record<string, string>;
|
|
29
|
+
/** Compose profiles to enable. */
|
|
30
|
+
profiles: string[];
|
|
31
|
+
/** service → replacement healthcheck `test`, for a repo's broken check. */
|
|
32
|
+
healthchecks?: Record<string, string[]>;
|
|
33
|
+
/**
|
|
34
|
+
* service → environment variables set on that service (override wins by
|
|
35
|
+
* key). For values a repo's compose file hard-codes, which `env` (compose
|
|
36
|
+
* `${VAR}` substitution) cannot change.
|
|
37
|
+
*/
|
|
38
|
+
serviceEnv?: Record<string, Record<string, string>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What `docker compose config --format json` tells us about a service. */
|
|
42
|
+
export interface ResolvedService {
|
|
43
|
+
container_name?: string;
|
|
44
|
+
ports?: unknown[];
|
|
45
|
+
networks?: Record<string, unknown> | null;
|
|
46
|
+
environment?: Record<string, string | null> | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ResolvedProject {
|
|
50
|
+
name: string;
|
|
51
|
+
services: Record<string, ResolvedService>;
|
|
52
|
+
networks?: Record<string, { name?: string }>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
|
+
|
|
4
|
+
import { product } from "../product.js";
|
|
5
|
+
export type TerminalApp = "iterm2" | "terminal";
|
|
6
|
+
|
|
7
|
+
export interface OpenTabOptions {
|
|
8
|
+
cmd: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
terminalApp?: TerminalApp;
|
|
12
|
+
/**
|
|
13
|
+
* Variables to export in the tab before running `cmd`. A tab is a fresh
|
|
14
|
+
* login shell, so den-resolved values (the org config's service_env) are only
|
|
15
|
+
* present if we put them there — without them a consumer repo silently falls
|
|
16
|
+
* back to its `../<repo>` sibling default and fails in a nested checkout.
|
|
17
|
+
*
|
|
18
|
+
* Keep this to non-secret values: it lands in the tab's shell history.
|
|
19
|
+
*/
|
|
20
|
+
env?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OpenedTab {
|
|
24
|
+
terminalApp: TerminalApp;
|
|
25
|
+
tabId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const APPLE_TERMINAL_APP = fs.existsSync(
|
|
29
|
+
"/System/Applications/Utilities/Terminal.app",
|
|
30
|
+
)
|
|
31
|
+
? "/System/Applications/Utilities/Terminal.app"
|
|
32
|
+
: "Terminal";
|
|
33
|
+
|
|
34
|
+
export function shellQuote(value: string): string {
|
|
35
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function appleScriptString(value: string): string {
|
|
39
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function automationHelp(appName: string): string {
|
|
43
|
+
return (
|
|
44
|
+
`Could not automate ${appName}. ` +
|
|
45
|
+
`Allow ${product().title}/your terminal to control Terminal, iTerm, and System Events in ` +
|
|
46
|
+
"System Settings > Privacy & Security > Automation."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runAppleScript(script: string, appName: string): string {
|
|
51
|
+
try {
|
|
52
|
+
return execFileSync("osascript", ["-e", script], {
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
55
|
+
}).trim();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const stderr =
|
|
58
|
+
typeof (error as { stderr?: unknown }).stderr === "string"
|
|
59
|
+
? ((error as { stderr: string }).stderr ?? "")
|
|
60
|
+
: "";
|
|
61
|
+
const message = stderr.trim() || (error as Error).message;
|
|
62
|
+
throw new Error(`${automationHelp(appName)} ${message}`.trim());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildTabCommand(opts: OpenTabOptions): string {
|
|
67
|
+
const exports = Object.entries(opts.env ?? {})
|
|
68
|
+
.filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value)
|
|
69
|
+
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
|
|
70
|
+
const prefix = exports.length > 0 ? `${exports.join(" ")} && ` : "";
|
|
71
|
+
return `${prefix}cd ${shellQuote(opts.cwd)} && ${opts.cmd}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function openITermTab(command: string): OpenedTab {
|
|
75
|
+
const script = `
|
|
76
|
+
tell application "iTerm"
|
|
77
|
+
activate
|
|
78
|
+
if (count of windows) = 0 then
|
|
79
|
+
set newWindow to (create window with default profile)
|
|
80
|
+
set newTab to current tab of newWindow
|
|
81
|
+
else
|
|
82
|
+
tell current window
|
|
83
|
+
set newTab to (create tab with default profile)
|
|
84
|
+
end tell
|
|
85
|
+
end if
|
|
86
|
+
tell current session of newTab
|
|
87
|
+
write text ${appleScriptString(command)}
|
|
88
|
+
end tell
|
|
89
|
+
return (id of current session of newTab as text)
|
|
90
|
+
end tell
|
|
91
|
+
`;
|
|
92
|
+
return { terminalApp: "iterm2", tabId: runAppleScript(script, "iTerm2") };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function openAppleTerminalTab(command: string): OpenedTab {
|
|
96
|
+
const script = `
|
|
97
|
+
tell application ${appleScriptString(APPLE_TERMINAL_APP)}
|
|
98
|
+
activate
|
|
99
|
+
if not (exists front window) then
|
|
100
|
+
do script ""
|
|
101
|
+
end if
|
|
102
|
+
end tell
|
|
103
|
+
delay 0.1
|
|
104
|
+
tell application "System Events"
|
|
105
|
+
tell process "Terminal"
|
|
106
|
+
keystroke "t" using command down
|
|
107
|
+
end tell
|
|
108
|
+
end tell
|
|
109
|
+
delay 0.1
|
|
110
|
+
tell application ${appleScriptString(APPLE_TERMINAL_APP)}
|
|
111
|
+
do script ${appleScriptString(command)} in selected tab of front window
|
|
112
|
+
return tty of selected tab of front window
|
|
113
|
+
end tell
|
|
114
|
+
`;
|
|
115
|
+
return {
|
|
116
|
+
terminalApp: "terminal",
|
|
117
|
+
tabId: runAppleScript(script, "Apple Terminal"),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function openTab(opts: OpenTabOptions): OpenedTab {
|
|
122
|
+
const terminalApp = opts.terminalApp ?? detectDefaultTerminalApp();
|
|
123
|
+
const command = buildTabCommand(opts);
|
|
124
|
+
if (terminalApp === "iterm2") return openITermTab(command);
|
|
125
|
+
return openAppleTerminalTab(command);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function closeTab(tabId: string, terminalApp: TerminalApp): void {
|
|
129
|
+
if (terminalApp === "iterm2") {
|
|
130
|
+
const script = `
|
|
131
|
+
tell application "iTerm"
|
|
132
|
+
repeat with w in windows
|
|
133
|
+
repeat with t in tabs of w
|
|
134
|
+
if (id of current session of t as text) is ${appleScriptString(tabId)} then
|
|
135
|
+
close t
|
|
136
|
+
return "closed"
|
|
137
|
+
end if
|
|
138
|
+
end repeat
|
|
139
|
+
end repeat
|
|
140
|
+
end tell
|
|
141
|
+
return "missing"
|
|
142
|
+
`;
|
|
143
|
+
runAppleScript(script, "iTerm2");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const script = `
|
|
148
|
+
tell application ${appleScriptString(APPLE_TERMINAL_APP)}
|
|
149
|
+
repeat with w in windows
|
|
150
|
+
repeat with t in tabs of w
|
|
151
|
+
try
|
|
152
|
+
if (tty of t) is ${appleScriptString(tabId)} then
|
|
153
|
+
close t
|
|
154
|
+
return "closed"
|
|
155
|
+
end if
|
|
156
|
+
end try
|
|
157
|
+
end repeat
|
|
158
|
+
end repeat
|
|
159
|
+
end tell
|
|
160
|
+
return "missing"
|
|
161
|
+
`;
|
|
162
|
+
runAppleScript(script, "Apple Terminal");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function tabExists(tabId: string, terminalApp: TerminalApp): boolean {
|
|
166
|
+
if (terminalApp === "iterm2") {
|
|
167
|
+
const script = `
|
|
168
|
+
tell application "iTerm"
|
|
169
|
+
repeat with w in windows
|
|
170
|
+
repeat with t in tabs of w
|
|
171
|
+
if (id of current session of t as text) is ${appleScriptString(tabId)} then return "yes"
|
|
172
|
+
end repeat
|
|
173
|
+
end repeat
|
|
174
|
+
end tell
|
|
175
|
+
return "no"
|
|
176
|
+
`;
|
|
177
|
+
return runAppleScript(script, "iTerm2") === "yes";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const script = `
|
|
181
|
+
tell application ${appleScriptString(APPLE_TERMINAL_APP)}
|
|
182
|
+
repeat with w in windows
|
|
183
|
+
repeat with t in tabs of w
|
|
184
|
+
try
|
|
185
|
+
if (tty of t) is ${appleScriptString(tabId)} then return "yes"
|
|
186
|
+
end try
|
|
187
|
+
end repeat
|
|
188
|
+
end repeat
|
|
189
|
+
end tell
|
|
190
|
+
return "no"
|
|
191
|
+
`;
|
|
192
|
+
return runAppleScript(script, "Apple Terminal") === "yes";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function resolveTerminalApp(key: string | undefined): TerminalApp {
|
|
196
|
+
if (!key) return detectDefaultTerminalApp();
|
|
197
|
+
if (key === "iterm2") return "iterm2";
|
|
198
|
+
if (key === "terminal") return "terminal";
|
|
199
|
+
if (key === "ghostty") {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Tab mode does not support Ghostty yet. Set \`${product().cli} config terminal=iterm2\`, \`${product().cli} config terminal=terminal\`, or run with \`--no-tabs\`.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Tab mode only supports iTerm2 and Apple Terminal. Configured terminal "${key}" is not supported; run with --no-tabs to use daemon mode.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function detectDefaultTerminalApp(): TerminalApp {
|
|
210
|
+
if (fs.existsSync("/Applications/iTerm.app")) return "iterm2";
|
|
211
|
+
return "terminal";
|
|
212
|
+
}
|