@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,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared health-check primitives.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for HTTP and shell pings — used by both the web
|
|
5
|
+
* server's /api/health endpoint and the CLI's status command.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { hasRuntime, repoKind } from "../repo-kind.js";
|
|
9
|
+
import * as https from "https";
|
|
10
|
+
import * as tls from "tls";
|
|
11
|
+
import { readEdgeConfig } from "../edge/config.js";
|
|
12
|
+
import { composeHealthCmd } from "../stacks/manifest-commands.js";
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
import * as dns from "dns";
|
|
16
|
+
import { spawn } from "child_process";
|
|
17
|
+
import { HEALTH_CHECK_TIMEOUT_MS, CLI_SPAWN_TIMEOUT_MS } from "../constants.js";
|
|
18
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
19
|
+
import { readCatalog } from "./catalog.js";
|
|
20
|
+
import {
|
|
21
|
+
getActiveWorkspace,
|
|
22
|
+
getFocusedWorkspaceName,
|
|
23
|
+
listWorkspaceServices,
|
|
24
|
+
readManifestAnyBranch,
|
|
25
|
+
} from "./workspaces.js";
|
|
26
|
+
import type { ResolvedService, ServiceCatalog } from "../types/index.js";
|
|
27
|
+
import type { Environment } from "../types/index.js";
|
|
28
|
+
|
|
29
|
+
import { product } from "../product.js";
|
|
30
|
+
export interface PingResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
status?: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ServiceHealthResult {
|
|
37
|
+
address: string;
|
|
38
|
+
repoName: string;
|
|
39
|
+
subsystem: string;
|
|
40
|
+
repoPath: string;
|
|
41
|
+
mode: "dev-managed" | "managed";
|
|
42
|
+
branch: string | null;
|
|
43
|
+
health: "ok" | "error" | "unknown";
|
|
44
|
+
healthDetail?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Name resolution ───────────────────────────────────────────────────────────
|
|
48
|
+
// Health checks resolve names with c-ares and connect by IP instead of letting
|
|
49
|
+
// fetch() call getaddrinfo.
|
|
50
|
+
//
|
|
51
|
+
// getaddrinfo runs on the 4-thread libuv pool and its requests are
|
|
52
|
+
// UNCANCELLABLE — aborting the fetch does not cancel the lookup. One slow
|
|
53
|
+
// resolver episode strands a lookup on every pool thread, and every later
|
|
54
|
+
// lookup in the process then queues behind them forever while each poll adds
|
|
55
|
+
// ~21 more. Observed in the long-lived web server: 84k pending
|
|
56
|
+
// GetAddrInfoReqWrap, ~1 GB RSS, and every HTTP check reporting a false
|
|
57
|
+
// "timeout" while the very same service answered by IP in 11ms. Only a restart
|
|
58
|
+
// cleared it, and it re-wedged within 16 minutes. c-ares runs on the event
|
|
59
|
+
// loop, honours its own timeout, and cannot wedge the pool.
|
|
60
|
+
//
|
|
61
|
+
// Bare names (shop-api) resolve through opctl's per-service /etc/resolver
|
|
62
|
+
// files. c-ares must be pointed at that resolver explicitly because, unlike
|
|
63
|
+
// getaddrinfo, it does not read /etc/resolver. Aiming there also skips those
|
|
64
|
+
// files' `nameserver 8.8.8.8` fallback, so a missed lookup can no longer hang
|
|
65
|
+
// on Google trying to resolve a private name.
|
|
66
|
+
//
|
|
67
|
+
// Deliberately NOT cached: opctl containers take dynamic IPs (no IPAMConfig),
|
|
68
|
+
// so a restarted service can move. Resolution costs ~0ms, so every check
|
|
69
|
+
// re-resolves and DNS stays authoritative — strictly fresher than getaddrinfo,
|
|
70
|
+
// whose answers the OS caches for the record TTL (60s here).
|
|
71
|
+
const DNS_TIMEOUT_MS = 1_500;
|
|
72
|
+
const LOCAL_RESOLVER = "127.0.0.1";
|
|
73
|
+
|
|
74
|
+
function isIpLiteral(host: string): boolean {
|
|
75
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let _bareResolver: dns.Resolver | null = null;
|
|
79
|
+
let _systemResolver: dns.Resolver | null = null;
|
|
80
|
+
|
|
81
|
+
function resolverFor(hostname: string): dns.Resolver {
|
|
82
|
+
// Bare names are opctl's and live on the local resolver; dotted names are
|
|
83
|
+
// real domains and use the system's configured servers.
|
|
84
|
+
if (!hostname.includes(".")) {
|
|
85
|
+
if (!_bareResolver) {
|
|
86
|
+
_bareResolver = new dns.Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 });
|
|
87
|
+
_bareResolver.setServers([LOCAL_RESOLVER]);
|
|
88
|
+
}
|
|
89
|
+
return _bareResolver;
|
|
90
|
+
}
|
|
91
|
+
if (!_systemResolver) {
|
|
92
|
+
_systemResolver = new dns.Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 });
|
|
93
|
+
}
|
|
94
|
+
return _systemResolver;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Test seam. Unit tests mock `fetch` and assert on the URL it receives; without
|
|
99
|
+
* this they would perform real DNS and assert against whatever IP this machine
|
|
100
|
+
* happens to hand out. Inject an identity resolver to keep them hermetic.
|
|
101
|
+
*/
|
|
102
|
+
let _resolveHost: (hostname: string) => Promise<string | null> =
|
|
103
|
+
resolveHostViaCares;
|
|
104
|
+
export function __setResolveHostForTests(
|
|
105
|
+
fn: ((hostname: string) => Promise<string | null>) | null,
|
|
106
|
+
): void {
|
|
107
|
+
_resolveHost = fn ?? resolveHostViaCares;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve to an IPv4 address via c-ares. null = could not resolve. */
|
|
111
|
+
function resolveHostViaCares(hostname: string): Promise<string | null> {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
let settled = false;
|
|
114
|
+
const done = (v: string | null): void => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
resolve(v);
|
|
118
|
+
};
|
|
119
|
+
// Backstop: c-ares' own timeout should fire first, but a health check must
|
|
120
|
+
// never wait on resolution longer than its own budget.
|
|
121
|
+
const guard = setTimeout(() => done(null), DNS_TIMEOUT_MS + 250);
|
|
122
|
+
try {
|
|
123
|
+
resolverFor(hostname).resolve4(hostname, (err, addrs) => {
|
|
124
|
+
clearTimeout(guard);
|
|
125
|
+
done(err || !addrs || addrs.length === 0 ? null : addrs[0]);
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
clearTimeout(guard);
|
|
129
|
+
done(null);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `localhost` and `*.localhost` are this machine (RFC 6761); else c-ares. */
|
|
135
|
+
async function resolveName(host: string): Promise<string | null> {
|
|
136
|
+
if (host === "localhost" || host.endsWith(".localhost")) return "127.0.0.1";
|
|
137
|
+
return _resolveHost(host);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Extra CAs trusted by HTTPS checks: the edge's own CA (its routes are signed
|
|
142
|
+
* by it), read once. Test seam: __setExtraCaForTests.
|
|
143
|
+
*/
|
|
144
|
+
let _extraCa: string[] | null = null;
|
|
145
|
+
function extraCa(): string[] {
|
|
146
|
+
if (_extraCa) return _extraCa;
|
|
147
|
+
_extraCa = [];
|
|
148
|
+
try {
|
|
149
|
+
// The edge's own CA, and the CA it was pointed at (`edge.caroot`, e.g. the
|
|
150
|
+
// laptop's mkcert CA that signs the projects' certificates).
|
|
151
|
+
const cfg = readEdgeConfig();
|
|
152
|
+
const pems = [path.join(cfg.stateDir, "ca", "rootCA.pem")];
|
|
153
|
+
if (cfg.caroot) pems.push(path.join(cfg.caroot, "rootCA.pem"));
|
|
154
|
+
for (const pem of pems)
|
|
155
|
+
if (fs.existsSync(pem)) _extraCa.push(fs.readFileSync(pem, "utf-8"));
|
|
156
|
+
} catch {
|
|
157
|
+
/* no edge config — public roots only */
|
|
158
|
+
}
|
|
159
|
+
return _extraCa;
|
|
160
|
+
}
|
|
161
|
+
export function __setExtraCaForTests(pems: string[] | null): void {
|
|
162
|
+
_extraCa = pems;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
|
|
167
|
+
* hostname for SNI and certificate verification; rewriting the URL to the IP,
|
|
168
|
+
* as plain HTTP does, makes every TLS check fail.
|
|
169
|
+
*/
|
|
170
|
+
async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
|
|
171
|
+
const host = parsed.hostname;
|
|
172
|
+
let ip = host;
|
|
173
|
+
if (!isIpLiteral(host)) {
|
|
174
|
+
const r = await resolveName(host);
|
|
175
|
+
if (!r) return { ok: false, error: "dns" };
|
|
176
|
+
ip = r;
|
|
177
|
+
}
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let settled = false;
|
|
180
|
+
const done = (v: PingResult) => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
resolve(v);
|
|
185
|
+
};
|
|
186
|
+
const req = https.request(
|
|
187
|
+
parsed,
|
|
188
|
+
{
|
|
189
|
+
method: "GET",
|
|
190
|
+
servername: isIpLiteral(host) ? undefined : host,
|
|
191
|
+
ca: [...tls.rootCertificates, ...extraCa()],
|
|
192
|
+
lookup: (_h, opts, cb) => {
|
|
193
|
+
// Node may ask for all addresses (autoSelectFamily).
|
|
194
|
+
if ((opts as { all?: boolean }).all)
|
|
195
|
+
(
|
|
196
|
+
cb as unknown as (
|
|
197
|
+
e: null,
|
|
198
|
+
a: { address: string; family: number }[],
|
|
199
|
+
) => void
|
|
200
|
+
)(null, [{ address: ip, family: ip.includes(":") ? 6 : 4 }]);
|
|
201
|
+
else cb(null, ip, ip.includes(":") ? 6 : 4);
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
(res) => {
|
|
205
|
+
const status = res.statusCode ?? 0;
|
|
206
|
+
res.resume(); // discard the body
|
|
207
|
+
done({
|
|
208
|
+
ok: status < 300 || (status >= 400 && status < 500),
|
|
209
|
+
status,
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
req.destroy();
|
|
215
|
+
done({ ok: false, error: "timeout" });
|
|
216
|
+
}, timeoutMs);
|
|
217
|
+
req.on("error", (e) =>
|
|
218
|
+
done({ ok: false, error: e.message || "unreachable" }),
|
|
219
|
+
);
|
|
220
|
+
req.end();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Ping primitives ───────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
async function pingOnce(
|
|
227
|
+
url: string,
|
|
228
|
+
timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
|
|
229
|
+
): Promise<PingResult> {
|
|
230
|
+
let target = url;
|
|
231
|
+
let hostHeader: string | undefined;
|
|
232
|
+
try {
|
|
233
|
+
const parsed = new URL(url);
|
|
234
|
+
const host = parsed.hostname;
|
|
235
|
+
if (parsed.protocol === "https:") return await pingHttps(parsed, timeoutMs);
|
|
236
|
+
if (host !== "localhost" && !isIpLiteral(host)) {
|
|
237
|
+
const ip = await resolveName(host);
|
|
238
|
+
// A name that will not resolve is down — say so in 1ms instead of
|
|
239
|
+
// burning the full timeout and calling it "timeout", which read as a
|
|
240
|
+
// slow service when it was really a missing DNS record.
|
|
241
|
+
if (!ip) return { ok: false, error: "dns" };
|
|
242
|
+
hostHeader = parsed.host; // name[:port] — preserves vhost routing
|
|
243
|
+
parsed.hostname = ip;
|
|
244
|
+
target = parsed.toString();
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
/* unparseable URL — leave it alone and let fetch report the failure */
|
|
248
|
+
}
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
251
|
+
try {
|
|
252
|
+
const res = await fetch(target, {
|
|
253
|
+
signal: controller.signal,
|
|
254
|
+
redirect: "manual",
|
|
255
|
+
...(hostHeader ? { headers: { Host: hostHeader } } : {}),
|
|
256
|
+
});
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
// 2xx = healthy. 4xx = healthy (service is up; auth/route 4xx still proves it's responding).
|
|
259
|
+
// 3xx = unhealthy: with redirect:"manual", a redirect means "go verify somewhere else"
|
|
260
|
+
// and we haven't confirmed anything. Guards against hostname-to-AWS-ALB resolution
|
|
261
|
+
// (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
|
|
262
|
+
// on a different port — the previous `< 500` check showed those as green.
|
|
263
|
+
// 5xx = unhealthy (service errored).
|
|
264
|
+
const ok = res.status < 300 || (res.status >= 400 && res.status < 500);
|
|
265
|
+
// Discard the body: only the status matters, and unconsumed bodies pin
|
|
266
|
+
// sockets in the long-lived web server (one leak per poll adds up).
|
|
267
|
+
void res.body?.cancel().catch(() => {});
|
|
268
|
+
return { ok, status: res.status };
|
|
269
|
+
} catch (e: unknown) {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
const err = e as Error;
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
error:
|
|
275
|
+
err.name === "AbortError" ? "timeout" : err.message || "unreachable",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Ping an HTTP/HTTPS URL. For Docker-style hostnames (no dots, not localhost)
|
|
282
|
+
* automatically retries against localhost on the same port as a fallback —
|
|
283
|
+
* these hostnames only resolve inside Docker itself.
|
|
284
|
+
*/
|
|
285
|
+
/** Fast timeout for the localhost fallback ping — localhost should respond in ms. */
|
|
286
|
+
const LOCALHOST_FALLBACK_TIMEOUT_MS = 1_500;
|
|
287
|
+
|
|
288
|
+
export async function pingHttp(
|
|
289
|
+
url: string,
|
|
290
|
+
timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
|
|
291
|
+
): Promise<PingResult> {
|
|
292
|
+
const result = await pingOnce(url, timeoutMs);
|
|
293
|
+
if (!result.ok) {
|
|
294
|
+
// Fall back to localhost for Docker-style hostnames regardless of how the
|
|
295
|
+
// first ping failed. Timeouts on the primary are legitimate candidates for
|
|
296
|
+
// retry: on macOS, VPN/`/etc/hosts` can resolve a Docker hostname to an
|
|
297
|
+
// AWS ALB on the wrong port, which hangs until abort — skipping the retry
|
|
298
|
+
// showed the service red even when the local container was up. We use a
|
|
299
|
+
// short timeout for the fallback since localhost should respond in ms.
|
|
300
|
+
try {
|
|
301
|
+
const parsed = new URL(url);
|
|
302
|
+
// NO fallback for default-port-80 URLs: opctl DNS resolves bare names
|
|
303
|
+
// (http://<repo>/healthz) on the host directly, so a network failure
|
|
304
|
+
// there means the service is genuinely down — and localhost:80 belongs
|
|
305
|
+
// to whichever OTHER container happens to publish host port 80 (e.g.
|
|
306
|
+
// shop-web), which both spams that service's logs with foreign
|
|
307
|
+
// /healthz probes every poll and risks reporting the wrong service.
|
|
308
|
+
const explicitPort = parsed.port !== "" && parsed.port !== "80";
|
|
309
|
+
if (
|
|
310
|
+
explicitPort &&
|
|
311
|
+
parsed.hostname !== "localhost" &&
|
|
312
|
+
!parsed.hostname.includes(".")
|
|
313
|
+
) {
|
|
314
|
+
// Rewrite only the hostname so the port, path and query are preserved
|
|
315
|
+
// (string-concatenating protocol+port dropped /healthz and emitted a
|
|
316
|
+
// stray trailing colon for default-port URLs like http://shop-api/healthz).
|
|
317
|
+
parsed.hostname = "localhost";
|
|
318
|
+
const fb = await pingOnce(
|
|
319
|
+
parsed.toString(),
|
|
320
|
+
LOCALHOST_FALLBACK_TIMEOUT_MS,
|
|
321
|
+
);
|
|
322
|
+
// On the fallback, only 2xx proves the service is up: localhost:<port>
|
|
323
|
+
// may be a DIFFERENT service, and a 4xx from a stranger (which counts
|
|
324
|
+
// as "up" on a direct ping) must not green-light this one.
|
|
325
|
+
if (fb.ok && fb.status !== undefined && fb.status >= 400) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
status: fb.status,
|
|
329
|
+
error: "fallback got non-2xx",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return fb;
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
/* ignore */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Run a shell command health check. Exit code 0 = ok.
|
|
343
|
+
* Pass extraEnv for AWS profile vars etc.
|
|
344
|
+
*
|
|
345
|
+
* `cwd` must be the service's repo path: health commands in .den.yml are
|
|
346
|
+
* repo-relative (e.g. `./scripts/orchestration/health.sh ddb`). Without it the
|
|
347
|
+
* relative path resolves against den's own directory and the shell exits 127
|
|
348
|
+
* (command not found) — which is exactly how a service whose local resources
|
|
349
|
+
* moved to infra-stack shows up red even when it's healthy.
|
|
350
|
+
*/
|
|
351
|
+
export async function pingShell(
|
|
352
|
+
cmd: string,
|
|
353
|
+
extraEnv: Record<string, string> = {},
|
|
354
|
+
cwd?: string,
|
|
355
|
+
): Promise<PingResult> {
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
let settled = false;
|
|
358
|
+
const settle = (r: PingResult): void => {
|
|
359
|
+
if (settled) return;
|
|
360
|
+
settled = true;
|
|
361
|
+
clearTimeout(guard);
|
|
362
|
+
resolve(r);
|
|
363
|
+
};
|
|
364
|
+
const proc = spawn(cmd, [], {
|
|
365
|
+
shell: true,
|
|
366
|
+
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
367
|
+
env: { ...process.env, ...extraEnv },
|
|
368
|
+
cwd,
|
|
369
|
+
// Output is never read: piped stdio would block a chatty script once the
|
|
370
|
+
// 64KB buffer fills, and 'close' would wait on grandchildren holding the
|
|
371
|
+
// pipes open. Nothing consumes it — don't create it.
|
|
372
|
+
stdio: "ignore",
|
|
373
|
+
});
|
|
374
|
+
// 'exit', NOT 'close': close waits for stdio to drain, which a killed
|
|
375
|
+
// shell's surviving grandchildren can hold open forever — one wedged
|
|
376
|
+
// health check then hangs every /api/health poll for good. The guard
|
|
377
|
+
// timer backstops even a spawn timeout that never fires.
|
|
378
|
+
const guard = setTimeout(
|
|
379
|
+
() => settle({ ok: false, error: "health check timed out" }),
|
|
380
|
+
CLI_SPAWN_TIMEOUT_MS + 1_000,
|
|
381
|
+
);
|
|
382
|
+
proc.on("exit", (code) =>
|
|
383
|
+
settle(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` }),
|
|
384
|
+
);
|
|
385
|
+
proc.on("error", (err) => settle({ ok: false, error: err.message }));
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Per-service health check ──────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Check health for a single resolved service (reads its manifest).
|
|
393
|
+
* `env` defaults to "local".
|
|
394
|
+
*/
|
|
395
|
+
export async function checkServiceHealth(
|
|
396
|
+
resolved: ResolvedService,
|
|
397
|
+
env: Environment = "local",
|
|
398
|
+
): Promise<ServiceHealthResult> {
|
|
399
|
+
const base: ServiceHealthResult = {
|
|
400
|
+
address: resolved.address,
|
|
401
|
+
repoName: resolved.repoName,
|
|
402
|
+
subsystem: resolved.subsystem,
|
|
403
|
+
repoPath: resolved.repoPath,
|
|
404
|
+
mode: resolved.mode,
|
|
405
|
+
branch: resolved.branch,
|
|
406
|
+
health: "unknown",
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
if (!fs.existsSync(resolved.repoPath)) {
|
|
410
|
+
return {
|
|
411
|
+
...base,
|
|
412
|
+
health: "error",
|
|
413
|
+
healthDetail: `repo path does not exist — run ${product().cli} setup`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const manifest = readManifest(resolved.repoPath);
|
|
418
|
+
if (!manifest) {
|
|
419
|
+
return {
|
|
420
|
+
...base,
|
|
421
|
+
health: "unknown",
|
|
422
|
+
healthDetail: `no ${product().manifestYml}`,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const composeHealth =
|
|
427
|
+
env === "local"
|
|
428
|
+
? composeHealthCmd(
|
|
429
|
+
manifest,
|
|
430
|
+
resolved.subsystem,
|
|
431
|
+
resolved.repoName,
|
|
432
|
+
getFocusedWorkspaceName() ?? "default",
|
|
433
|
+
)
|
|
434
|
+
: null;
|
|
435
|
+
const healthCfg =
|
|
436
|
+
manifest.services?.[resolved.subsystem]?.health?.[env] ??
|
|
437
|
+
(composeHealth
|
|
438
|
+
? { type: "shell" as const, cmd: composeHealth }
|
|
439
|
+
: undefined);
|
|
440
|
+
if (!healthCfg) {
|
|
441
|
+
// Conventional local discovery: opctl exposes each system on the host at
|
|
442
|
+
// http://<repo> (port 80), and stack services serve /healthz. When the
|
|
443
|
+
// PRIMARY service (entrypoint, or a repo's only service) declares no local
|
|
444
|
+
// health check, probe the convention — a repo like jobs-runtime serves
|
|
445
|
+
// /healthz without listing it in the manifest. Success → "ok"; failure
|
|
446
|
+
// stays "unknown" (we can't distinguish "down" from "no endpoint").
|
|
447
|
+
const serviceKeys = Object.keys(manifest.services ?? {});
|
|
448
|
+
const isPrimary = manifest.entrypoint
|
|
449
|
+
? resolved.subsystem === manifest.entrypoint
|
|
450
|
+
: serviceKeys.length === 1;
|
|
451
|
+
if (env === "local" && isPrimary) {
|
|
452
|
+
const conventional = `http://${resolved.repoName}/healthz`;
|
|
453
|
+
const probe = await pingHttp(conventional);
|
|
454
|
+
// Convention-GUESSED endpoint: only 2xx proves this service answered.
|
|
455
|
+
// pingHttp counts 4xx as ok (right for DECLARED endpoints, where 401/403
|
|
456
|
+
// still prove the service is up) — but on the bare port-80 convention,
|
|
457
|
+
// host DNS may hand the request to a different container or an ALB, and
|
|
458
|
+
// a stranger's 404 must not report "ok" (it would feed up's skip-healthy
|
|
459
|
+
// decision and skip starting a genuinely-down service).
|
|
460
|
+
if (probe.ok && probe.status !== undefined && probe.status < 300) {
|
|
461
|
+
return {
|
|
462
|
+
...base,
|
|
463
|
+
health: "ok",
|
|
464
|
+
healthDetail: `conventional ${conventional}`,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
...base,
|
|
470
|
+
health: "unknown",
|
|
471
|
+
healthDetail: "no health check configured",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const result =
|
|
476
|
+
healthCfg.type === "shell"
|
|
477
|
+
? await pingShell(healthCfg.cmd, {}, resolved.repoPath)
|
|
478
|
+
: await pingHttp(healthCfg.url);
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
...base,
|
|
482
|
+
health: result.ok ? "ok" : "error",
|
|
483
|
+
healthDetail:
|
|
484
|
+
result.error ??
|
|
485
|
+
(!result.ok && result.status !== undefined
|
|
486
|
+
? `http ${result.status}`
|
|
487
|
+
: undefined),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── Catalog-wide health scan ──────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Check health for every service visible in the active workspace.
|
|
495
|
+
* Uses listWorkspaceServices (which expands mono-repo entries and resolves
|
|
496
|
+
* paths from workspace entries) so CLI and server stay consistent.
|
|
497
|
+
*
|
|
498
|
+
* Falls back to iterating the catalog directly when no workspace is active
|
|
499
|
+
* (e.g. first-run before any workspace is configured).
|
|
500
|
+
*/
|
|
501
|
+
export async function checkCatalogHealth(
|
|
502
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
503
|
+
env: Environment = "local",
|
|
504
|
+
): Promise<ServiceHealthResult[]> {
|
|
505
|
+
const ws = getActiveWorkspace();
|
|
506
|
+
|
|
507
|
+
if (!ws) return [];
|
|
508
|
+
// Modules and libraries have no runtime, so no health of their own.
|
|
509
|
+
const resolved = listWorkspaceServices(ws.config, catalog, ws.name).filter(
|
|
510
|
+
(r) => hasRuntime(repoKind(r.repoPath)),
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
return Promise.all(resolved.map((r) => checkServiceHealth(r, env)));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Collapse a multi-service repo's per-subsystem health rows into a single
|
|
518
|
+
* repo-level row keyed on the manifest `entrypoint` (the front-door app).
|
|
519
|
+
*
|
|
520
|
+
* Stack app-repos declare their shared-infra sidecars (pg/redis/sqs/ddb/s3) as
|
|
521
|
+
* manifest `services:`, and those sidecars health-check the shared infra-stack
|
|
522
|
+
* stack — so they read "ok" whenever infra-stack is up, making a down app look
|
|
523
|
+
* partially running. Rolling up to the entrypoint answers the real question
|
|
524
|
+
* ("is this repo's service up?") instead.
|
|
525
|
+
*
|
|
526
|
+
* A repo is collapsed only when it has multiple rows AND its manifest declares an
|
|
527
|
+
* `entrypoint` present among them. Repos with no entrypoint (e.g. the
|
|
528
|
+
* infra-stack infra provider, whose subsystems ARE the meaningful services)
|
|
529
|
+
* keep their subsystem breakdown. Single-row repos pass through unchanged.
|
|
530
|
+
*/
|
|
531
|
+
export function rollupRepoHealth(
|
|
532
|
+
results: ServiceHealthResult[],
|
|
533
|
+
getEntrypoint: (repoName: string, repoPath: string) => string | undefined = (
|
|
534
|
+
repoName,
|
|
535
|
+
repoPath,
|
|
536
|
+
) => readManifestAnyBranch(repoName, repoPath)?.entrypoint ?? undefined,
|
|
537
|
+
): ServiceHealthResult[] {
|
|
538
|
+
// Group by repo, preserving first-seen order.
|
|
539
|
+
const groups = new Map<string, ServiceHealthResult[]>();
|
|
540
|
+
for (const r of results) {
|
|
541
|
+
const g = groups.get(r.repoName);
|
|
542
|
+
if (g) g.push(r);
|
|
543
|
+
else groups.set(r.repoName, [r]);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const out: ServiceHealthResult[] = [];
|
|
547
|
+
for (const [repoName, rows] of groups) {
|
|
548
|
+
if (rows.length <= 1) {
|
|
549
|
+
out.push(...rows);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const entrypoint = getEntrypoint(repoName, rows[0].repoPath);
|
|
553
|
+
const epRow = entrypoint
|
|
554
|
+
? rows.find((r) => r.subsystem === entrypoint)
|
|
555
|
+
: undefined;
|
|
556
|
+
if (epRow) {
|
|
557
|
+
// Show the entrypoint's health under the clean repo-name address.
|
|
558
|
+
out.push({ ...epRow, address: repoName });
|
|
559
|
+
} else {
|
|
560
|
+
// No entrypoint → infra provider; keep the subsystem breakdown.
|
|
561
|
+
out.push(...rows);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Order health rows to match the derived graph's top-down flow — entry repo
|
|
569
|
+
* first, foundation last (e.g. shop-web … infra-stack), the same direction the
|
|
570
|
+
* web graph uses. Pass the repo-name order (typically `orderForDown(graph)`).
|
|
571
|
+
*
|
|
572
|
+
* Stable within a repo, so a multi-service repo's subsystems stay grouped in
|
|
573
|
+
* their original sub-order. Rows whose repo isn't in the graph sort to the end,
|
|
574
|
+
* preserving their relative order.
|
|
575
|
+
*/
|
|
576
|
+
export function sortByRepoOrder(
|
|
577
|
+
results: ServiceHealthResult[],
|
|
578
|
+
order: string[],
|
|
579
|
+
): ServiceHealthResult[] {
|
|
580
|
+
const rank = new Map(order.map((repo, i) => [repo, i]));
|
|
581
|
+
return results
|
|
582
|
+
.map((r, i) => ({ r, i }))
|
|
583
|
+
.sort((a, b) => {
|
|
584
|
+
const ra = rank.get(a.r.repoName) ?? Number.POSITIVE_INFINITY;
|
|
585
|
+
const rb = rank.get(b.r.repoName) ?? Number.POSITIVE_INFINITY;
|
|
586
|
+
return ra - rb || a.i - b.i;
|
|
587
|
+
})
|
|
588
|
+
.map((x) => x.r);
|
|
589
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Applications for a workspace, from config and repo manifests. */
|
|
2
|
+
import { readDenConfig } from "../den-config.js";
|
|
3
|
+
import { resolveApplications, type Application } from "../applications.js";
|
|
4
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
5
|
+
import { contributesTo, repoKind } from "../repo-kind.js";
|
|
6
|
+
import type { RepoKind } from "../types/index.js";
|
|
7
|
+
import { deriveGraph } from "./derivation/derive-graph.js";
|
|
8
|
+
import type { DerivedGraph } from "./derivation/types.js";
|
|
9
|
+
import { getWorkspaceServices } from "./service-runner.js";
|
|
10
|
+
import { readOrg, type Org } from "../org.js";
|
|
11
|
+
|
|
12
|
+
export function loadApplications(workspace?: string): Application[] {
|
|
13
|
+
const services = getWorkspaceServices(workspace);
|
|
14
|
+
const repos = [...new Set(services.map((s) => s.repoName))];
|
|
15
|
+
const manifestApplications: Record<string, string> = {};
|
|
16
|
+
const kinds: Record<string, RepoKind> = {};
|
|
17
|
+
const contributes: Record<string, string[]> = {};
|
|
18
|
+
// The graph only tells a module/library where it ships; derive it lazily.
|
|
19
|
+
let graph: DerivedGraph | undefined;
|
|
20
|
+
for (const s of services) {
|
|
21
|
+
const a = readManifest(s.repoPath)?.application;
|
|
22
|
+
if (a) manifestApplications[s.repoName] = a;
|
|
23
|
+
const kind = repoKind(s.repoPath);
|
|
24
|
+
if (kind !== "service") {
|
|
25
|
+
graph ??= deriveGraph({
|
|
26
|
+
scope: "all",
|
|
27
|
+
...(workspace ? { workspace } : {}),
|
|
28
|
+
});
|
|
29
|
+
kinds[s.repoName] = kind;
|
|
30
|
+
contributes[s.repoName] = contributesTo(s.repoName, s.repoPath, graph);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return resolveApplications({
|
|
34
|
+
repos,
|
|
35
|
+
config: readDenConfig().applications,
|
|
36
|
+
manifestApplications,
|
|
37
|
+
kinds,
|
|
38
|
+
contributesTo: contributes,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An application with what the workspace runs of it. */
|
|
43
|
+
export interface ApplicationView extends Application {
|
|
44
|
+
/** repo → its service addresses in the workspace ("repo" or "repo/sub"). */
|
|
45
|
+
services: Record<string, string[]>;
|
|
46
|
+
/** Every service address in the application, for app-wide up/down. */
|
|
47
|
+
addresses: string[];
|
|
48
|
+
/** Repos the team config lists for it that are not in this workspace. */
|
|
49
|
+
absent: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Org > Application > Repo > Services for a workspace (CLI `apps`, web UI). */
|
|
53
|
+
export function loadApplicationTree(workspace?: string): {
|
|
54
|
+
org?: Org;
|
|
55
|
+
applications: ApplicationView[];
|
|
56
|
+
} {
|
|
57
|
+
const byRepo = new Map<string, string[]>();
|
|
58
|
+
for (const s of getWorkspaceServices(workspace)) {
|
|
59
|
+
if (!byRepo.has(s.repoName)) byRepo.set(s.repoName, []);
|
|
60
|
+
byRepo.get(s.repoName)!.push(s.address);
|
|
61
|
+
}
|
|
62
|
+
const config = readDenConfig().applications ?? {};
|
|
63
|
+
const org = readOrg();
|
|
64
|
+
return {
|
|
65
|
+
...(org ? { org } : {}),
|
|
66
|
+
applications: loadApplications(workspace).map((a) => {
|
|
67
|
+
const services = Object.fromEntries(
|
|
68
|
+
a.repos.map((r) => [r, byRepo.get(r) ?? []]),
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
...a,
|
|
72
|
+
services,
|
|
73
|
+
addresses: Object.values(services).flat(),
|
|
74
|
+
absent: (config[a.name]?.repos ?? []).filter((r) => !byRepo.has(r)),
|
|
75
|
+
};
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
}
|