@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,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log history: full container/daemon log history, paged backwards.
|
|
3
|
+
*
|
|
4
|
+
* Docker retains the complete log of a running container (json-file driver),
|
|
5
|
+
* but `docker logs --until <ts> --tail N` returns nothing (docker applies
|
|
6
|
+
* --tail BEFORE the time filter), so timestamp cursors can't page backwards.
|
|
7
|
+
* Instead: history-before-now is APPEND-ONLY, so the first request snapshots
|
|
8
|
+
* `docker logs --timestamps` to a file once per container instance, and pages
|
|
9
|
+
* are served from that file by BYTE-OFFSET cursors — exact, immutable, and
|
|
10
|
+
* O(chunk) per page. Host-run services page their den daemon log file
|
|
11
|
+
* directly (also append-only).
|
|
12
|
+
*
|
|
13
|
+
* Future-proofing: the API cursor is an OPAQUE STRING. Local sources encode a
|
|
14
|
+
* byte offset; a cloudwatch source can later encode a filter-log-events
|
|
15
|
+
* backward token without any client change. `resolveHistorySource` is the
|
|
16
|
+
* single extension point per env.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { execFile, spawnSync } from "child_process";
|
|
22
|
+
|
|
23
|
+
import { findConventionalContainer } from "../../shared/environment-manager/opctl.js";
|
|
24
|
+
import {
|
|
25
|
+
resolveService,
|
|
26
|
+
hasRunningOpctlContainers,
|
|
27
|
+
} from "../../shared/environment-manager/service-runner.js";
|
|
28
|
+
import { checkServiceHealth } from "../../shared/environment-manager/health.js";
|
|
29
|
+
import { getServiceLogPath } from "../../shared/process/spawn-streaming.js";
|
|
30
|
+
import { readManifest } from "../../shared/manifest/read-manifest.js";
|
|
31
|
+
import type { Environment, LogSource } from "../../shared/types/index.js";
|
|
32
|
+
import { getStateDir } from "../../shared/paths.js";
|
|
33
|
+
|
|
34
|
+
const HISTORY_DIR = path.join(getStateDir(), "history");
|
|
35
|
+
|
|
36
|
+
// ── Source resolution ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export type HistorySource =
|
|
39
|
+
| { kind: "docker"; container: string }
|
|
40
|
+
| { kind: "file"; path: string }
|
|
41
|
+
| { kind: "unsupported"; reason: string };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Repo/service names as they appear in catalogs and manifests: must start
|
|
45
|
+
* alphanumeric (rejects ".", "..", dotfiles) with no path separators. This is
|
|
46
|
+
* the security boundary for every history/search entry point — these values
|
|
47
|
+
* reach `getServiceLogPath` (which mkdirs and opens files) and docker CLIs.
|
|
48
|
+
*/
|
|
49
|
+
const SAFE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
50
|
+
|
|
51
|
+
export function isSafeServiceName(name: string): boolean {
|
|
52
|
+
return SAFE_NAME_RE.test(name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract a docker `--filter key=value` from a manifest logs shell command
|
|
57
|
+
* (every fleet manifest resolves its container via `docker ps --filter …`).
|
|
58
|
+
* Returns the filter argument (e.g. "name=shop-postgres") or null.
|
|
59
|
+
*/
|
|
60
|
+
export function parseManifestDockerFilter(cmd: string): string | null {
|
|
61
|
+
const m = cmd.match(/--filter[= ]['"]?((?:name|publish|expose)=[^'"\s]+)/);
|
|
62
|
+
return m ? m[1] : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve where a service's log HISTORY lives. Local env only in v1 —
|
|
67
|
+
* non-local envs return `unsupported` (cloudwatch backward paging is a
|
|
68
|
+
* follow-up that plugs in here).
|
|
69
|
+
*/
|
|
70
|
+
export function resolveHistorySource(
|
|
71
|
+
repoName: string,
|
|
72
|
+
svcName: string,
|
|
73
|
+
env: string,
|
|
74
|
+
): HistorySource {
|
|
75
|
+
// Security boundary: these names flow into filesystem paths
|
|
76
|
+
// (getServiceLogPath mkdirs + reads `<repo>/<service>.log`) and docker
|
|
77
|
+
// invocations. Reject anything that isn't a plain catalog-style name.
|
|
78
|
+
if (!isSafeServiceName(repoName) || !isSafeServiceName(svcName)) {
|
|
79
|
+
return { kind: "unsupported", reason: "invalid repo/service name" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (env !== "local") {
|
|
83
|
+
return {
|
|
84
|
+
kind: "unsupported",
|
|
85
|
+
reason: `history for env "${env}" is not supported yet (local only)`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resolved =
|
|
90
|
+
resolveService(`${repoName}/${svcName}`) ?? resolveService(repoName);
|
|
91
|
+
const repoPath = resolved?.repoPath;
|
|
92
|
+
|
|
93
|
+
// 1. Conventional opctl container (opctl_<base>_<hash>).
|
|
94
|
+
const conventional = findConventionalContainer(repoName, repoPath, svcName);
|
|
95
|
+
if (conventional) return { kind: "docker", container: conventional };
|
|
96
|
+
|
|
97
|
+
// 2. Container named by the manifest log command's docker ps filter.
|
|
98
|
+
const manifest = repoPath ? readManifest(repoPath) : null;
|
|
99
|
+
const logSource = manifest?.services?.[svcName]?.logs?.["local"] as
|
|
100
|
+
| LogSource
|
|
101
|
+
| undefined;
|
|
102
|
+
if (logSource?.type === "shell" && logSource.cmd) {
|
|
103
|
+
const filter = parseManifestDockerFilter(logSource.cmd);
|
|
104
|
+
if (filter) {
|
|
105
|
+
const out = spawnSync(
|
|
106
|
+
"docker",
|
|
107
|
+
["ps", "--filter", filter, "--format", "{{.Names}}"],
|
|
108
|
+
{ encoding: "utf-8", timeout: 5_000 },
|
|
109
|
+
);
|
|
110
|
+
const name = out.stdout?.split("\n").map((l) => l.trim())[0];
|
|
111
|
+
if (name) return { kind: "docker", container: name };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. Host-run process — the den daemon log file holds history since
|
|
116
|
+
// process start.
|
|
117
|
+
const daemonPath = getServiceLogPath(repoName, svcName);
|
|
118
|
+
if (fs.existsSync(daemonPath)) return { kind: "file", path: daemonPath };
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
kind: "unsupported",
|
|
122
|
+
reason: `no running container or daemon log found for ${repoName}/${svcName}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Snapshots ─────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Materialize the immutable history file for a source. Docker: one-time
|
|
130
|
+
* `docker logs --timestamps` dump per container instance (backward pages are
|
|
131
|
+
* immutable, so an existing snapshot is never stale — new lines belong to the
|
|
132
|
+
* live tail stream). File sources ARE their own snapshot (append-only).
|
|
133
|
+
*/
|
|
134
|
+
export function ensureSnapshot(source: HistorySource): string | null {
|
|
135
|
+
if (source.kind === "file") return source.path;
|
|
136
|
+
if (source.kind !== "docker") return null;
|
|
137
|
+
|
|
138
|
+
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
|
139
|
+
const snapPath = path.join(HISTORY_DIR, `${source.container}.log`);
|
|
140
|
+
if (fs.existsSync(snapPath)) return snapPath;
|
|
141
|
+
|
|
142
|
+
const tmp = `${snapPath}.tmp`;
|
|
143
|
+
const fd = fs.openSync(tmp, "w");
|
|
144
|
+
try {
|
|
145
|
+
// stdout straight to the file — a multi-GB log must never transit a
|
|
146
|
+
// JS string buffer.
|
|
147
|
+
const res = spawnSync(
|
|
148
|
+
"docker",
|
|
149
|
+
["logs", "--timestamps", source.container],
|
|
150
|
+
{ stdio: ["ignore", fd, fd], timeout: 120_000 },
|
|
151
|
+
);
|
|
152
|
+
if (res.status !== 0) {
|
|
153
|
+
fs.rmSync(tmp, { force: true });
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
fs.closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
fs.renameSync(tmp, snapPath);
|
|
160
|
+
return snapPath;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Drop snapshots whose container no longer exists (their docker history is
|
|
165
|
+
* gone with the container; the snapshot is orphaned disk). Call at web-server
|
|
166
|
+
* startup, like pruneDeadDaemons().
|
|
167
|
+
*/
|
|
168
|
+
export function pruneDeadSnapshots(): number {
|
|
169
|
+
let removed = 0;
|
|
170
|
+
let entries: string[];
|
|
171
|
+
try {
|
|
172
|
+
entries = fs.readdirSync(HISTORY_DIR);
|
|
173
|
+
} catch {
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
const out = spawnSync("docker", ["ps", "-a", "--format", "{{.Names}}"], {
|
|
177
|
+
encoding: "utf-8",
|
|
178
|
+
timeout: 10_000,
|
|
179
|
+
});
|
|
180
|
+
if (out.status !== 0) return 0; // docker unavailable — keep everything
|
|
181
|
+
const alive = new Set(
|
|
182
|
+
(out.stdout ?? "")
|
|
183
|
+
.split("\n")
|
|
184
|
+
.map((l) => l.trim())
|
|
185
|
+
.filter(Boolean),
|
|
186
|
+
);
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
if (!entry.endsWith(".log")) continue;
|
|
189
|
+
if (!alive.has(entry.slice(0, -4))) {
|
|
190
|
+
try {
|
|
191
|
+
fs.rmSync(path.join(HISTORY_DIR, entry), { force: true });
|
|
192
|
+
removed++;
|
|
193
|
+
} catch {
|
|
194
|
+
/* best effort */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return removed;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Byte-offset backward paging ───────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export interface HistoryPage {
|
|
204
|
+
/** Lines in file order (oldest first). */
|
|
205
|
+
lines: string[];
|
|
206
|
+
/** Opaque cursor for the NEXT (older) page — byte offset of lines[0]. */
|
|
207
|
+
cursor: string;
|
|
208
|
+
/** True when lines[0] is the first line of the file. */
|
|
209
|
+
atStart: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const DEFAULT_BLOCK = 256 * 1024;
|
|
213
|
+
|
|
214
|
+
/** Safety valve for pathological single-line files (no newlines for GBs). */
|
|
215
|
+
const MAX_CARRY = 64 * 1024 * 1024;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read up to `limit` lines ENDING just before byte offset `cursor`
|
|
219
|
+
* (cursor null/"" = end of file). Reads backwards in bounded blocks —
|
|
220
|
+
* O(returned bytes), no full-file scan, no shell.
|
|
221
|
+
*
|
|
222
|
+
* Cursor contract: the returned cursor is the byte offset of the FIRST
|
|
223
|
+
* returned line; passing it back returns the lines immediately before it.
|
|
224
|
+
* Offsets into an append-only file are immutable, so pages never shift.
|
|
225
|
+
*/
|
|
226
|
+
export function readPageBefore(
|
|
227
|
+
filePath: string,
|
|
228
|
+
cursor: string | null,
|
|
229
|
+
limit: number,
|
|
230
|
+
blockSize: number = DEFAULT_BLOCK,
|
|
231
|
+
): HistoryPage {
|
|
232
|
+
const fd = fs.openSync(filePath, "r");
|
|
233
|
+
try {
|
|
234
|
+
const size = fs.fstatSync(fd).size;
|
|
235
|
+
const end = cursor ? Math.min(parseInt(cursor, 10) || 0, size) : size;
|
|
236
|
+
if (end <= 0 || limit <= 0)
|
|
237
|
+
return { lines: [], cursor: "0", atStart: true };
|
|
238
|
+
|
|
239
|
+
// The region [0, end) ends with the terminator of its last line (except
|
|
240
|
+
// possibly at EOF with no trailing newline). Work with the region's
|
|
241
|
+
// CONTENT — exclude that trailing newline so split() yields real lines.
|
|
242
|
+
const lastByte = Buffer.alloc(1);
|
|
243
|
+
fs.readSync(fd, lastByte, 0, 1, end - 1);
|
|
244
|
+
const logicalEnd = lastByte[0] === 0x0a ? end - 1 : end;
|
|
245
|
+
if (logicalEnd <= 0) return { lines: [], cursor: "0", atStart: true };
|
|
246
|
+
|
|
247
|
+
// Read backwards until the buffer holds at least `limit` newlines
|
|
248
|
+
// (⇒ limit+1 segments, first possibly partial) or we reach byte 0.
|
|
249
|
+
let pos = logicalEnd;
|
|
250
|
+
let carry = Buffer.alloc(0);
|
|
251
|
+
let newlines = 0;
|
|
252
|
+
while (pos > 0 && newlines < limit && carry.length < MAX_CARRY) {
|
|
253
|
+
const readStart = Math.max(0, pos - blockSize);
|
|
254
|
+
const buf = Buffer.alloc(pos - readStart);
|
|
255
|
+
fs.readSync(fd, buf, 0, buf.length, readStart);
|
|
256
|
+
carry = Buffer.concat([buf, carry]);
|
|
257
|
+
pos = readStart;
|
|
258
|
+
newlines = 0;
|
|
259
|
+
for (const b of carry) if (b === 0x0a) newlines++;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// NB: byte math below must run on the RAW segments — \r stripping for
|
|
263
|
+
// display happens at the end so cursors stay exact.
|
|
264
|
+
const segs = carry.toString("utf-8").split("\n");
|
|
265
|
+
let lines: string[];
|
|
266
|
+
if (pos === 0 && segs.length <= limit) {
|
|
267
|
+
lines = segs; // whole remaining region fits
|
|
268
|
+
} else if (pos > 0 && segs.length <= limit) {
|
|
269
|
+
// MAX_CARRY tripped inside one enormous line: represent it truncated
|
|
270
|
+
// and jump the cursor to the read boundary so paging stays monotonic.
|
|
271
|
+
return {
|
|
272
|
+
lines: [
|
|
273
|
+
"… [oversized line truncated by history pager] …",
|
|
274
|
+
...segs.slice(1).map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l)),
|
|
275
|
+
],
|
|
276
|
+
cursor: String(pos),
|
|
277
|
+
atStart: false,
|
|
278
|
+
};
|
|
279
|
+
} else {
|
|
280
|
+
// Last `limit` segments are guaranteed-complete lines; segs[0] may be a
|
|
281
|
+
// partial line cut at a block boundary and is discarded implicitly.
|
|
282
|
+
lines = segs.slice(-limit);
|
|
283
|
+
}
|
|
284
|
+
const startOffset =
|
|
285
|
+
logicalEnd - Buffer.byteLength(lines.join("\n"), "utf-8");
|
|
286
|
+
return {
|
|
287
|
+
// CRLF sources (docker preserves container \r\n) — strip for display
|
|
288
|
+
// AFTER the byte math; live-tail lines are \r-free, so this keeps the
|
|
289
|
+
// client's seam dedupe exact.
|
|
290
|
+
lines: lines.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l)),
|
|
291
|
+
cursor: String(startOffset),
|
|
292
|
+
atStart: startOffset === 0,
|
|
293
|
+
};
|
|
294
|
+
} finally {
|
|
295
|
+
fs.closeSync(fd);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Cross-system history search ───────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
export interface SearchMatch {
|
|
302
|
+
/** RFC3339 timestamp when the source has one (docker --timestamps). */
|
|
303
|
+
ts: string | null;
|
|
304
|
+
line: string;
|
|
305
|
+
/** Surrounding context lines (already timestamp-stripped like `line`). */
|
|
306
|
+
context: string[];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface SystemSearchResult {
|
|
310
|
+
system: string;
|
|
311
|
+
matches: SearchMatch[];
|
|
312
|
+
truncated: boolean;
|
|
313
|
+
error?: string;
|
|
314
|
+
/** True when the system was excluded because it is not running. */
|
|
315
|
+
skipped?: boolean;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const TS_PREFIX = /^(\d{4}-\d{2}-\d{2}T\S+Z)\s?/;
|
|
319
|
+
|
|
320
|
+
function splitTs(raw: string): { ts: string | null; line: string } {
|
|
321
|
+
const m = raw.match(TS_PREFIX);
|
|
322
|
+
return m
|
|
323
|
+
? { ts: m[1], line: raw.slice(m[0].length) }
|
|
324
|
+
: { ts: null, line: raw };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse `grep -n -C k` output (`N:line` matches, `N-line` context, `--`
|
|
329
|
+
* group separators) into structured matches.
|
|
330
|
+
*/
|
|
331
|
+
export function parseGrepOutput(
|
|
332
|
+
out: string,
|
|
333
|
+
maxMatches: number,
|
|
334
|
+
): { matches: SearchMatch[]; truncated: boolean } {
|
|
335
|
+
const matches: SearchMatch[] = [];
|
|
336
|
+
let current: SearchMatch | null = null;
|
|
337
|
+
let truncated = false;
|
|
338
|
+
for (let raw of out.split("\n")) {
|
|
339
|
+
// CRLF logs: `.` doesn't match \r in JS regexes, so strip it first.
|
|
340
|
+
if (raw.endsWith("\r")) raw = raw.slice(0, -1);
|
|
341
|
+
if (!raw || raw === "--") {
|
|
342
|
+
current = null;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const m = raw.match(/^(\d+)([:-])(.*)$/);
|
|
346
|
+
if (!m) continue;
|
|
347
|
+
const [, , sep, rest] = m;
|
|
348
|
+
const { ts, line } = splitTs(rest);
|
|
349
|
+
if (sep === ":") {
|
|
350
|
+
if (matches.length >= maxMatches) {
|
|
351
|
+
truncated = true;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
current = { ts, line, context: [] };
|
|
355
|
+
matches.push(current);
|
|
356
|
+
} else if (current) {
|
|
357
|
+
current.context.push(line);
|
|
358
|
+
} else if (matches.length < maxMatches) {
|
|
359
|
+
// Leading context before the first match of a group.
|
|
360
|
+
current = null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { matches, truncated };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const MAX_MATCHES_PER_SYSTEM = 200;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Search one system's FULL history (snapshot / daemon file) for a
|
|
370
|
+
* case-insensitive fixed string. Bounded output.
|
|
371
|
+
*/
|
|
372
|
+
export async function searchSystemHistory(
|
|
373
|
+
system: { repo: string; service: string },
|
|
374
|
+
q: string,
|
|
375
|
+
env: string,
|
|
376
|
+
): Promise<SystemSearchResult> {
|
|
377
|
+
const label = `${system.repo}/${system.service}`;
|
|
378
|
+
const source = resolveHistorySource(system.repo, system.service, env);
|
|
379
|
+
if (source.kind === "unsupported") {
|
|
380
|
+
return {
|
|
381
|
+
system: label,
|
|
382
|
+
matches: [],
|
|
383
|
+
truncated: false,
|
|
384
|
+
error: source.reason,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Search covers RUNNING systems only. Docker sources are running by
|
|
389
|
+
// construction (`docker ps` lists live containers). File sources leak in
|
|
390
|
+
// stopped systems — their daemon log survives the process — so gate them
|
|
391
|
+
// on actual run-state (same test as the workspace down-skip: opctl
|
|
392
|
+
// containers, or a healthy probe for host-run processes).
|
|
393
|
+
if (source.kind === "file") {
|
|
394
|
+
const resolved =
|
|
395
|
+
resolveService(`${system.repo}/${system.service}`) ??
|
|
396
|
+
resolveService(system.repo);
|
|
397
|
+
const running =
|
|
398
|
+
resolved !== null &&
|
|
399
|
+
(hasRunningOpctlContainers(resolved) ||
|
|
400
|
+
(await checkServiceHealth(resolved, env as Environment)).health ===
|
|
401
|
+
"ok");
|
|
402
|
+
if (!running) {
|
|
403
|
+
return { system: label, matches: [], truncated: false, skipped: true };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const file = ensureSnapshot(source);
|
|
408
|
+
if (!file) {
|
|
409
|
+
return {
|
|
410
|
+
system: label,
|
|
411
|
+
matches: [],
|
|
412
|
+
truncated: false,
|
|
413
|
+
error: "could not snapshot container log",
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return new Promise((resolve) => {
|
|
418
|
+
// -m bounds matching lines server-side; +context stays small. 10 MB
|
|
419
|
+
// buffer is far above 200 matches × context.
|
|
420
|
+
execFile(
|
|
421
|
+
"grep",
|
|
422
|
+
[
|
|
423
|
+
"-inF",
|
|
424
|
+
"-m",
|
|
425
|
+
String(MAX_MATCHES_PER_SYSTEM + 1),
|
|
426
|
+
"-C",
|
|
427
|
+
"2",
|
|
428
|
+
"--",
|
|
429
|
+
q,
|
|
430
|
+
file,
|
|
431
|
+
],
|
|
432
|
+
{ maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
|
|
433
|
+
(err, stdout) => {
|
|
434
|
+
// grep exit 1 = no matches; treat other failures as empty-with-error.
|
|
435
|
+
if (err && (err as { code?: number }).code !== 1 && !stdout) {
|
|
436
|
+
resolve({
|
|
437
|
+
system: label,
|
|
438
|
+
matches: [],
|
|
439
|
+
truncated: false,
|
|
440
|
+
error: err.message.slice(0, 200),
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const { matches, truncated } = parseGrepOutput(
|
|
445
|
+
stdout ?? "",
|
|
446
|
+
MAX_MATCHES_PER_SYSTEM,
|
|
447
|
+
);
|
|
448
|
+
resolve({ system: label, matches, truncated });
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { loadCurrentPerfRunReport } from "../../shared/perf/report-store.js";
|
|
2
|
+
import type { Pillar } from "../../shared/types/index.js";
|
|
3
|
+
|
|
4
|
+
interface RunDonePayloadInput {
|
|
5
|
+
pillar: Pillar;
|
|
6
|
+
exitCode: number;
|
|
7
|
+
duration: number;
|
|
8
|
+
rootDir?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getRunDonePayload(input: RunDonePayloadInput): {
|
|
12
|
+
type: "done";
|
|
13
|
+
exitCode: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
perfReport?: {
|
|
16
|
+
available: boolean;
|
|
17
|
+
url: string;
|
|
18
|
+
};
|
|
19
|
+
} {
|
|
20
|
+
const base = {
|
|
21
|
+
type: "done" as const,
|
|
22
|
+
exitCode: input.exitCode,
|
|
23
|
+
duration: input.duration,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (input.pillar !== "performance") {
|
|
27
|
+
return base;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const report = loadCurrentPerfRunReport(input.rootDir);
|
|
31
|
+
if (!report) {
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
perfReport: {
|
|
38
|
+
available: true,
|
|
39
|
+
url: "/api/perf-report/current",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|