@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,119 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Environment,
|
|
3
|
+
RawManifest,
|
|
4
|
+
ServiceHealthConfig,
|
|
5
|
+
} from "./types/index.js";
|
|
6
|
+
|
|
7
|
+
export interface ResolveManifestBaseUrlOptions {
|
|
8
|
+
manifest: RawManifest;
|
|
9
|
+
repoName: string;
|
|
10
|
+
serviceName: string;
|
|
11
|
+
environment: Environment;
|
|
12
|
+
baseUrlKey: "api" | "web";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResolveServiceBaseUrlOptions {
|
|
16
|
+
baseUrlKey: "api" | "web";
|
|
17
|
+
configuredUrl: string;
|
|
18
|
+
manifestUrl?: string | null;
|
|
19
|
+
overrideUrl?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function healthUrlForConfig(healthConfig?: ServiceHealthConfig): string | null {
|
|
23
|
+
if (!healthConfig) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ("type" in healthConfig && healthConfig.type === "shell") {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return healthConfig.url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeServiceBaseUrl(
|
|
35
|
+
healthUrl: string,
|
|
36
|
+
baseUrlKey: "api" | "web",
|
|
37
|
+
): string {
|
|
38
|
+
if (baseUrlKey !== "api") {
|
|
39
|
+
return healthUrl;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const url = new URL(healthUrl);
|
|
43
|
+
url.pathname =
|
|
44
|
+
url.pathname.replace(/\/(healthz|readyz|version)\/?$/, "") || "/";
|
|
45
|
+
|
|
46
|
+
const normalized = url.toString();
|
|
47
|
+
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive a base URL from the manifest's `services.*.health.<env>` entry.
|
|
52
|
+
*
|
|
53
|
+
* Lookup order: `services[serviceName]`, then `services[repoName]`, then the
|
|
54
|
+
* manifest's declared `entrypoint`.
|
|
55
|
+
*
|
|
56
|
+
* The entrypoint step matters because `serviceName` here is the *pillar module*
|
|
57
|
+
* name (smoke passes `svcName` from `smoke.<name>`, perf passes
|
|
58
|
+
* `moduleConfig.service`), which need not match any `services:` key. A repo
|
|
59
|
+
* whose modules are named for what they test rather than for the service —
|
|
60
|
+
* `tier1`, `search`, `ingest` — matched neither of the first two lookups, so
|
|
61
|
+
* this returned null and callers silently fell back to
|
|
62
|
+
* `config/environments/<env>.json -> baseUrls.api`, i.e. shop's API. Repos
|
|
63
|
+
* appeared to work only when a module happened to share the service's name.
|
|
64
|
+
*
|
|
65
|
+
* `entrypoint` is precisely the manifest's statement of which service is the
|
|
66
|
+
* front door, and `service-runner.ts` already resolves an ambiguous subsystem
|
|
67
|
+
* the same way.
|
|
68
|
+
*
|
|
69
|
+
* For `baseUrlKey === "api"`, strips trailing `/healthz`, `/readyz`, or
|
|
70
|
+
* `/version` so the health endpoint becomes a usable API root.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveManifestBaseUrl({
|
|
73
|
+
manifest,
|
|
74
|
+
repoName,
|
|
75
|
+
serviceName,
|
|
76
|
+
environment,
|
|
77
|
+
baseUrlKey,
|
|
78
|
+
}: ResolveManifestBaseUrlOptions): string | null {
|
|
79
|
+
const entrypointConfig = manifest.entrypoint
|
|
80
|
+
? manifest.services?.[manifest.entrypoint]
|
|
81
|
+
: undefined;
|
|
82
|
+
const serviceConfig =
|
|
83
|
+
manifest.services?.[serviceName] ??
|
|
84
|
+
manifest.services?.[repoName] ??
|
|
85
|
+
entrypointConfig;
|
|
86
|
+
const healthConfig = serviceConfig?.health?.[environment];
|
|
87
|
+
const healthUrl = healthUrlForConfig(healthConfig);
|
|
88
|
+
if (!healthUrl) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return normalizeServiceBaseUrl(healthUrl, baseUrlKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Precedence-based URL resolver shared across pillars.
|
|
97
|
+
*
|
|
98
|
+
* 1. Explicit override (`BASE_API_URL` env, only for `baseUrlKey === "api"`)
|
|
99
|
+
* 2. Manifest-derived URL from `services.*.health.<env>`
|
|
100
|
+
* 3. Configured URL from `config/environments/<env>.json`
|
|
101
|
+
*/
|
|
102
|
+
export function resolveServiceBaseUrl({
|
|
103
|
+
baseUrlKey,
|
|
104
|
+
configuredUrl,
|
|
105
|
+
manifestUrl,
|
|
106
|
+
overrideUrl,
|
|
107
|
+
}: ResolveServiceBaseUrlOptions): string {
|
|
108
|
+
const trimmedOverride = baseUrlKey === "api" ? overrideUrl?.trim() : "";
|
|
109
|
+
if (trimmedOverride) {
|
|
110
|
+
return trimmedOverride;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const trimmedManifestUrl = manifestUrl?.trim();
|
|
114
|
+
if (trimmedManifestUrl) {
|
|
115
|
+
return trimmedManifestUrl;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return configuredUrl;
|
|
119
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployed branch/SHA resolver
|
|
3
|
+
*
|
|
4
|
+
* Uses the GitHub Deployments API (via `gh`) to find the exact commit SHA
|
|
5
|
+
* that is currently deployed to a given environment. This is the authoritative
|
|
6
|
+
* source of truth — more precise than a branch name, and independent of what
|
|
7
|
+
* happens to be on the local machine.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. GET /repos/{owner}/{repo}/deployments?environment={env}&per_page=20
|
|
11
|
+
* 2. Walk deployments newest-first, fetch each one's statuses
|
|
12
|
+
* 3. Return the first deployment whose latest status is "success"
|
|
13
|
+
*
|
|
14
|
+
* Why not just take deployment[0]? A deployment record is created when a
|
|
15
|
+
* deploy *starts*, not when it *succeeds*. If the most recent deploy is still
|
|
16
|
+
* in progress or failed, we want the last known-good one.
|
|
17
|
+
*
|
|
18
|
+
* Requires: `gh` CLI authenticated (`gh auth login`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execSync } from "child_process";
|
|
22
|
+
|
|
23
|
+
export interface DeployedRef {
|
|
24
|
+
/** Exact commit SHA deployed to this environment. */
|
|
25
|
+
sha: string;
|
|
26
|
+
/** Branch the commit was deployed from (informational). */
|
|
27
|
+
ref: string;
|
|
28
|
+
/** GitHub environment name. */
|
|
29
|
+
environment: string;
|
|
30
|
+
/** ISO timestamp of the deployment. */
|
|
31
|
+
deployedAt: string;
|
|
32
|
+
/** GitHub deployment ID. */
|
|
33
|
+
deploymentId: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface GhDeployment {
|
|
37
|
+
id: number;
|
|
38
|
+
sha: string;
|
|
39
|
+
ref: string;
|
|
40
|
+
environment: string;
|
|
41
|
+
created_at: string;
|
|
42
|
+
statuses_url: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface GhDeploymentStatus {
|
|
46
|
+
state: string; // "success" | "failure" | "error" | "pending" | "in_progress" | "queued" | "inactive"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function gh(args: string): string {
|
|
50
|
+
try {
|
|
51
|
+
return execSync(`gh ${args}`, {
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
}).trim();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const msg = (err as { stderr?: string }).stderr?.trim() ?? String(err);
|
|
57
|
+
throw new Error(`gh command failed: ${msg}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetch the latest deployment status state for a deployment.
|
|
63
|
+
* Returns null if no statuses exist yet (deployment just created).
|
|
64
|
+
*/
|
|
65
|
+
function getLatestDeploymentState(
|
|
66
|
+
owner: string,
|
|
67
|
+
repo: string,
|
|
68
|
+
deploymentId: number,
|
|
69
|
+
): string | null {
|
|
70
|
+
try {
|
|
71
|
+
const raw = gh(
|
|
72
|
+
`api "/repos/${owner}/${repo}/deployments/${deploymentId}/statuses?per_page=1"`,
|
|
73
|
+
);
|
|
74
|
+
const statuses: GhDeploymentStatus[] = JSON.parse(raw);
|
|
75
|
+
return statuses[0]?.state ?? null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the latest *successfully* deployed ref for a repo+environment.
|
|
83
|
+
*
|
|
84
|
+
* @param owner GitHub org/user (e.g. "acme")
|
|
85
|
+
* @param repo GitHub repo name (e.g. "api")
|
|
86
|
+
* @param environment GitHub environment name (e.g. "staging")
|
|
87
|
+
* @param limit Max deployments to inspect before giving up (default 20)
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveDeployedRef(
|
|
90
|
+
owner: string,
|
|
91
|
+
repo: string,
|
|
92
|
+
environment: string,
|
|
93
|
+
limit = 20,
|
|
94
|
+
): Promise<DeployedRef> {
|
|
95
|
+
// Check gh is available and authenticated
|
|
96
|
+
try {
|
|
97
|
+
gh("auth status --active");
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const raw = gh(
|
|
103
|
+
`api "/repos/${owner}/${repo}/deployments?environment=${environment}&per_page=${limit}"`,
|
|
104
|
+
);
|
|
105
|
+
const deployments: GhDeployment[] = JSON.parse(raw);
|
|
106
|
+
|
|
107
|
+
if (!deployments.length) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`No deployments found for ${owner}/${repo} on environment "${environment}". ` +
|
|
110
|
+
`Has it been deployed via GitHub Actions?`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Walk newest-first, find the last successful one
|
|
115
|
+
for (const d of deployments) {
|
|
116
|
+
const state = getLatestDeploymentState(owner, repo, d.id);
|
|
117
|
+
if (state === "success") {
|
|
118
|
+
return {
|
|
119
|
+
sha: d.sha,
|
|
120
|
+
ref: d.ref,
|
|
121
|
+
environment: d.environment,
|
|
122
|
+
deployedAt: d.created_at,
|
|
123
|
+
deploymentId: d.id,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// No successful deployment found in the window — warn and use most recent
|
|
129
|
+
const latest = deployments[0];
|
|
130
|
+
console.warn(
|
|
131
|
+
` ⚠ No successful deployment found for ${owner}/${repo}@${environment} ` +
|
|
132
|
+
`in the last ${limit} deploys. Using most recent (${latest.sha.slice(0, 8)}).`,
|
|
133
|
+
);
|
|
134
|
+
return {
|
|
135
|
+
sha: latest.sha,
|
|
136
|
+
ref: latest.ref,
|
|
137
|
+
environment: latest.environment,
|
|
138
|
+
deployedAt: latest.created_at,
|
|
139
|
+
deploymentId: latest.id,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git remote URL utilities
|
|
3
|
+
*
|
|
4
|
+
* Extracts the GitHub owner/repo from a git remote URL so the rest of the
|
|
5
|
+
* smoke pipeline can call the GitHub API without requiring this info to be
|
|
6
|
+
* hardcoded in .den.yml.
|
|
7
|
+
*
|
|
8
|
+
* Supported URL formats:
|
|
9
|
+
* https://github.com/acme/api.git
|
|
10
|
+
* https://github.com/acme/api
|
|
11
|
+
* git@github.com:acme/api.git
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
|
|
16
|
+
export interface GitHubCoords {
|
|
17
|
+
owner: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run `git remote get-url origin` inside the given directory.
|
|
23
|
+
* Returns the raw remote URL string, or null if the directory is not a git
|
|
24
|
+
* repo or has no origin remote.
|
|
25
|
+
*/
|
|
26
|
+
export function getGitRemoteOrigin(repoPath: string): string | null {
|
|
27
|
+
try {
|
|
28
|
+
return execSync("git remote get-url origin", {
|
|
29
|
+
cwd: repoPath,
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
}).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse GitHub owner and repo from a remote URL.
|
|
40
|
+
* Returns null if the URL is not a recognisable GitHub URL.
|
|
41
|
+
*/
|
|
42
|
+
export function parseGitHubOwnerRepo(remoteUrl: string): GitHubCoords | null {
|
|
43
|
+
// HTTPS: https://github.com/owner/repo[.git]
|
|
44
|
+
const httpsMatch = remoteUrl.match(
|
|
45
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
46
|
+
);
|
|
47
|
+
if (httpsMatch) {
|
|
48
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// SSH: git@github.com:owner/repo[.git]
|
|
52
|
+
const sshMatch = remoteUrl.match(
|
|
53
|
+
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
54
|
+
);
|
|
55
|
+
if (sshMatch) {
|
|
56
|
+
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convenience: get GitHub coords for a local repo path.
|
|
64
|
+
* Returns null if the remote is absent or not a GitHub remote.
|
|
65
|
+
*/
|
|
66
|
+
export function getGitHubCoords(repoPath: string): GitHubCoords | null {
|
|
67
|
+
const origin = getGitRemoteOrigin(repoPath);
|
|
68
|
+
if (!origin) return null;
|
|
69
|
+
return parseGitHubOwnerRepo(origin);
|
|
70
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test repo cloner
|
|
3
|
+
*
|
|
4
|
+
* Manages .smoke-clones/{repoName}/{environment}/ — shallow clones of
|
|
5
|
+
* registered repos checked out at the exact SHA deployed to each environment.
|
|
6
|
+
*
|
|
7
|
+
* Directory layout:
|
|
8
|
+
* .smoke-clones/
|
|
9
|
+
* api/
|
|
10
|
+
* staging/ ← shallow clone at deployed SHA
|
|
11
|
+
* .deployed-sha ← cached SHA (avoids redundant re-clones)
|
|
12
|
+
*
|
|
13
|
+
* Freshness strategy:
|
|
14
|
+
* - Read .deployed-sha from disk
|
|
15
|
+
* - If it matches the target SHA → already up to date, return path immediately
|
|
16
|
+
* - If it differs (or file absent) → fetch + checkout + update stamp
|
|
17
|
+
*
|
|
18
|
+
* Git authentication:
|
|
19
|
+
* Uses `gh auth git-credential` as the git credential helper so the
|
|
20
|
+
* existing `gh` session covers private repos without separate SSH setup.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execSync } from "child_process";
|
|
24
|
+
import * as fs from "fs";
|
|
25
|
+
import * as path from "path";
|
|
26
|
+
import type { DeployedRef } from "./branch-resolver.js";
|
|
27
|
+
import { getDistRoot } from "../paths.js";
|
|
28
|
+
import { assertSafeCloneDestination } from "../environment-manager/clone-guard.js";
|
|
29
|
+
|
|
30
|
+
// Anchored to the den root, NOT process.cwd() — this module CLONES repos,
|
|
31
|
+
// and a cwd-derived destination once bulk-cloned into an unrelated checkout.
|
|
32
|
+
const CLONES_DIR = path.resolve(getDistRoot(), ".smoke-clones");
|
|
33
|
+
const DEPLOYED_SHA_FILE = ".deployed-sha";
|
|
34
|
+
|
|
35
|
+
/** Absolute path to the clone directory for a repo+environment pair. */
|
|
36
|
+
export function cloneDirFor(repoName: string, environment: string): string {
|
|
37
|
+
return path.join(CLONES_DIR, repoName, environment);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function run(cmd: string, cwd?: string): void {
|
|
41
|
+
execSync(cmd, {
|
|
42
|
+
cwd,
|
|
43
|
+
stdio: "inherit",
|
|
44
|
+
env: {
|
|
45
|
+
...process.env,
|
|
46
|
+
// Route git credentials through `gh` so private repos work without SSH
|
|
47
|
+
GIT_CREDENTIAL_HELPER: "!gh auth git-credential",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runCapture(cmd: string, cwd?: string): string {
|
|
53
|
+
return execSync(cmd, {
|
|
54
|
+
cwd,
|
|
55
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
env: {
|
|
58
|
+
...process.env,
|
|
59
|
+
GIT_CREDENTIAL_HELPER: "!gh auth git-credential",
|
|
60
|
+
},
|
|
61
|
+
}).trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ensure the repo is cloned at the given SHA.
|
|
66
|
+
* Returns the absolute path to the clone root.
|
|
67
|
+
*
|
|
68
|
+
* @param repoName Short name (matches .den.yml `name` field, e.g. "api")
|
|
69
|
+
* @param environment Den environment key (e.g. "staging") — used for path only
|
|
70
|
+
* @param originUrl Git remote URL to clone from
|
|
71
|
+
* @param ref Deployed ref from branch-resolver
|
|
72
|
+
*/
|
|
73
|
+
export function ensureRepoAtSha(
|
|
74
|
+
repoName: string,
|
|
75
|
+
environment: string,
|
|
76
|
+
originUrl: string,
|
|
77
|
+
ref: DeployedRef,
|
|
78
|
+
): string {
|
|
79
|
+
const cloneDir = cloneDirFor(repoName, environment);
|
|
80
|
+
const shaFile = path.join(cloneDir, DEPLOYED_SHA_FILE);
|
|
81
|
+
|
|
82
|
+
// Fast path: already at the right SHA
|
|
83
|
+
if (fs.existsSync(shaFile)) {
|
|
84
|
+
const cached = fs.readFileSync(shaFile, "utf-8").trim();
|
|
85
|
+
if (cached === ref.sha) {
|
|
86
|
+
console.log(
|
|
87
|
+
` ✓ ${repoName}@${environment}: already at ${ref.sha.slice(0, 8)} (${ref.ref})`,
|
|
88
|
+
);
|
|
89
|
+
return cloneDir;
|
|
90
|
+
}
|
|
91
|
+
console.log(
|
|
92
|
+
` ↻ ${repoName}@${environment}: updating ${cached.slice(0, 8)} → ${ref.sha.slice(0, 8)} (${ref.ref})`,
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
console.log(
|
|
96
|
+
` ⬇ ${repoName}@${environment}: cloning at ${ref.sha.slice(0, 8)} (${ref.ref})`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!fs.existsSync(cloneDir)) {
|
|
101
|
+
assertSafeCloneDestination(cloneDir, repoName);
|
|
102
|
+
// Initial clone — shallow, blobless (history not needed, just the tree)
|
|
103
|
+
fs.mkdirSync(cloneDir, { recursive: true });
|
|
104
|
+
try {
|
|
105
|
+
run(
|
|
106
|
+
`git clone --filter=blob:none --depth=1 --branch "${ref.ref}" ` +
|
|
107
|
+
`-c credential.helper="!gh auth git-credential" ` +
|
|
108
|
+
`"${originUrl}" "${cloneDir}"`,
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
// Branch may not exist by that name (e.g. detached SHA) — try bare clone
|
|
112
|
+
fs.rmSync(cloneDir, { recursive: true, force: true });
|
|
113
|
+
fs.mkdirSync(cloneDir, { recursive: true });
|
|
114
|
+
run(
|
|
115
|
+
`git clone --filter=blob:none --depth=1 ` +
|
|
116
|
+
`-c credential.helper="!gh auth git-credential" ` +
|
|
117
|
+
`"${originUrl}" "${cloneDir}"`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fetch the exact SHA (in case shallow clone landed on a different commit)
|
|
123
|
+
const currentSha = runCapture("git rev-parse HEAD", cloneDir);
|
|
124
|
+
if (currentSha !== ref.sha) {
|
|
125
|
+
try {
|
|
126
|
+
run(
|
|
127
|
+
`git -c credential.helper="!gh auth git-credential" ` +
|
|
128
|
+
`fetch --depth=1 origin "${ref.sha}"`,
|
|
129
|
+
cloneDir,
|
|
130
|
+
);
|
|
131
|
+
run(`git checkout "${ref.sha}"`, cloneDir);
|
|
132
|
+
} catch {
|
|
133
|
+
// Some git servers don't allow fetching by SHA directly — fall back to
|
|
134
|
+
// fetching the branch and checking out the SHA
|
|
135
|
+
run(
|
|
136
|
+
`git -c credential.helper="!gh auth git-credential" ` +
|
|
137
|
+
`fetch --depth=1 origin "${ref.ref}"`,
|
|
138
|
+
cloneDir,
|
|
139
|
+
);
|
|
140
|
+
run(`git checkout "${ref.sha}"`, cloneDir);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Write stamp file
|
|
145
|
+
fs.writeFileSync(shaFile, ref.sha);
|
|
146
|
+
|
|
147
|
+
console.log(
|
|
148
|
+
` ✓ ${repoName}@${environment}: ready at ${ref.sha.slice(0, 8)}`,
|
|
149
|
+
);
|
|
150
|
+
return cloneDir;
|
|
151
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stacks come from two places:
|
|
3
|
+
* - a repo's own `.den.yml` `compose:` section (preferred), for every
|
|
4
|
+
* repo in the focused workspace;
|
|
5
|
+
* - `stacks:` in config/den.yml, for repos that don't carry a manifest.
|
|
6
|
+
* A manifest stack wins over a config stack of the same name.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as yaml from "yaml";
|
|
11
|
+
import { getDistRoot } from "../paths.js";
|
|
12
|
+
import { readCatalog } from "../environment-manager/catalog.js";
|
|
13
|
+
import {
|
|
14
|
+
getActiveWorkspace,
|
|
15
|
+
getWorkspaceByName,
|
|
16
|
+
listWorkspaceServices,
|
|
17
|
+
} from "../environment-manager/workspaces.js";
|
|
18
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
19
|
+
import type { ManifestComposeConfig } from "../types/index.js";
|
|
20
|
+
import { assertStackName, parseTarget } from "./render.js";
|
|
21
|
+
import type { StackConfig } from "./types.js";
|
|
22
|
+
|
|
23
|
+
import { product } from "../product.js";
|
|
24
|
+
type Raw = Partial<Omit<StackConfig, "name">>;
|
|
25
|
+
|
|
26
|
+
function list(v: unknown, what: string): string[] {
|
|
27
|
+
if (v === undefined) return [];
|
|
28
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
|
|
29
|
+
throw new Error(`${what} must be a list of strings`);
|
|
30
|
+
return v as string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toStack(
|
|
34
|
+
name: string,
|
|
35
|
+
raw: Raw | ManifestComposeConfig,
|
|
36
|
+
repo: string,
|
|
37
|
+
repoPath: string | undefined,
|
|
38
|
+
where: string,
|
|
39
|
+
): StackConfig {
|
|
40
|
+
assertStackName(name);
|
|
41
|
+
const routes = raw.routes ?? {};
|
|
42
|
+
Object.values(routes).forEach(parseTarget);
|
|
43
|
+
const files = list(raw.files, `${where}.files`);
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
repo,
|
|
47
|
+
...(repoPath ? { repoPath } : {}),
|
|
48
|
+
dir: raw.dir,
|
|
49
|
+
files: files.length ? files : ["docker-compose.yml"],
|
|
50
|
+
routes,
|
|
51
|
+
generate: list(raw.generate, `${where}.generate`),
|
|
52
|
+
uses: list(raw.uses, `${where}.uses`),
|
|
53
|
+
env: Object.fromEntries(
|
|
54
|
+
Object.entries(raw.env ?? {}).map(([k, v]) => [k, String(v)]),
|
|
55
|
+
),
|
|
56
|
+
profiles: list(raw.profiles, `${where}.profiles`),
|
|
57
|
+
...("healthchecks" in raw && raw.healthchecks
|
|
58
|
+
? { healthchecks: raw.healthchecks }
|
|
59
|
+
: {}),
|
|
60
|
+
...("service_env" in raw && raw.service_env
|
|
61
|
+
? {
|
|
62
|
+
serviceEnv: Object.fromEntries(
|
|
63
|
+
Object.entries(raw.service_env).map(([svc, vars]) => [
|
|
64
|
+
svc,
|
|
65
|
+
Object.fromEntries(
|
|
66
|
+
Object.entries(vars).map(([k, v]) => [k, String(v)]),
|
|
67
|
+
),
|
|
68
|
+
]),
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stack name for a repo's manifest compose section. */
|
|
76
|
+
export function manifestStackName(
|
|
77
|
+
manifest: { name: string; compose?: ManifestComposeConfig },
|
|
78
|
+
repoName: string,
|
|
79
|
+
): string {
|
|
80
|
+
return manifest.compose?.name ?? manifest.name ?? repoName;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `compose:` stacks declared by repos in a workspace (default: focused). */
|
|
84
|
+
export function readManifestStacks(
|
|
85
|
+
workspace?: string,
|
|
86
|
+
): Record<string, StackConfig> {
|
|
87
|
+
const ws = workspace ? getWorkspaceByName(workspace) : getActiveWorkspace();
|
|
88
|
+
if (!ws) return {};
|
|
89
|
+
const out: Record<string, StackConfig> = {};
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
for (const svc of listWorkspaceServices(ws.config, readCatalog(), ws.name)) {
|
|
92
|
+
if (seen.has(svc.repoPath) || !fs.existsSync(svc.repoPath)) continue;
|
|
93
|
+
seen.add(svc.repoPath);
|
|
94
|
+
const m = readManifest(svc.repoPath);
|
|
95
|
+
if (!m?.compose) continue;
|
|
96
|
+
const name = manifestStackName(m, svc.repoName);
|
|
97
|
+
out[name] = toStack(
|
|
98
|
+
name,
|
|
99
|
+
m.compose,
|
|
100
|
+
svc.repoName,
|
|
101
|
+
svc.repoPath,
|
|
102
|
+
`${svc.repoName}/${product().manifestYml} compose`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readStacks(
|
|
109
|
+
root: string = getDistRoot(),
|
|
110
|
+
opts: { workspace?: string; manifests?: boolean } = {},
|
|
111
|
+
): Record<string, StackConfig> {
|
|
112
|
+
let parsed: { stacks?: Record<string, Raw> } | null = null;
|
|
113
|
+
try {
|
|
114
|
+
parsed = yaml.parse(
|
|
115
|
+
fs.readFileSync(path.join(root, product().configFile), "utf-8"),
|
|
116
|
+
);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
119
|
+
}
|
|
120
|
+
const out: Record<string, StackConfig> = {};
|
|
121
|
+
for (const [name, raw] of Object.entries(parsed?.stacks ?? {})) {
|
|
122
|
+
if (!raw?.repo) throw new Error(`stacks.${name}.repo is required`);
|
|
123
|
+
out[name] = toStack(name, raw, raw.repo, undefined, `stacks.${name}`);
|
|
124
|
+
}
|
|
125
|
+
if (opts.manifests !== false)
|
|
126
|
+
Object.assign(out, readManifestStacks(opts.workspace));
|
|
127
|
+
|
|
128
|
+
// `uses` may name a stack or the repo that declares it.
|
|
129
|
+
const byRepo = new Map(Object.values(out).map((s) => [s.repo, s.name]));
|
|
130
|
+
for (const s of Object.values(out)) {
|
|
131
|
+
s.uses = s.uses.map((u) => (out[u] ? u : (byRepo.get(u) ?? u)));
|
|
132
|
+
for (const u of s.uses)
|
|
133
|
+
if (!out[u])
|
|
134
|
+
throw new Error(`stack ${s.name}: uses names unknown stack "${u}"`);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|