@monster-orange/den 0.2.7 → 0.3.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 +1655 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +619 -3
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +63 -0
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +185 -7
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +26 -3
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/environment-manager/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +328 -0
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +24 -5
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +62 -3
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +649 -72
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +298 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +151 -24
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +29 -45
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +590 -45
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +15 -3
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +24 -4
- package/src/ui/server/release-service.ts +25 -3
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +55 -9
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { hasRuntime, repoKind } from "../repo-kind.js";
|
|
10
10
|
import { effectiveLocalCommands } from "../stacks/manifest-commands.js";
|
|
11
11
|
import { manifestStackName } from "../stacks/config.js";
|
|
12
|
+
import { claimHealthyStack } from "../stacks/owners.js";
|
|
12
13
|
import fs from "fs";
|
|
13
14
|
import path from "path";
|
|
14
15
|
import { readManifest } from "../manifest/read-manifest.js";
|
|
@@ -72,6 +73,17 @@ import type { ChildProcess } from "child_process";
|
|
|
72
73
|
import { getDistRoot, getStateDir } from "../paths.js";
|
|
73
74
|
|
|
74
75
|
import { product } from "../product.js";
|
|
76
|
+
import {
|
|
77
|
+
adoptLegacyProcess,
|
|
78
|
+
busyListenerText,
|
|
79
|
+
servicePort,
|
|
80
|
+
} from "../process/legacy-adopt.js";
|
|
81
|
+
import {
|
|
82
|
+
processGroupStateSync,
|
|
83
|
+
recordProcessGroup,
|
|
84
|
+
stopProcessGroup,
|
|
85
|
+
type RecordedProcessGroup,
|
|
86
|
+
} from "../process/process-group.js";
|
|
75
87
|
// Re-export so existing callers don't need to change their import path.
|
|
76
88
|
export type { ServiceHealthResult };
|
|
77
89
|
export { checkServiceHealth, checkCatalogHealth };
|
|
@@ -108,6 +120,23 @@ export interface DaemonRecord {
|
|
|
108
120
|
* written before 0.2.7.
|
|
109
121
|
*/
|
|
110
122
|
root?: string;
|
|
123
|
+
/**
|
|
124
|
+
* The process group a detached `up` started (its leader's pid), so `down`
|
|
125
|
+
* can stop a service that has no down command and no container
|
|
126
|
+
* (0.3.0-dev.37). Missing on records written before that.
|
|
127
|
+
*/
|
|
128
|
+
pgid?: number;
|
|
129
|
+
/**
|
|
130
|
+
* The group leader's start time (`ps -o lstart=`). `down` signals the
|
|
131
|
+
* group only while the process holding `pgid` started at this time, so a
|
|
132
|
+
* reused pid is never signalled.
|
|
133
|
+
*/
|
|
134
|
+
pgidStartedAt?: string;
|
|
135
|
+
/**
|
|
136
|
+
* A pid some writers before 0.3.0-dev.37 kept. Only a hint for adopting
|
|
137
|
+
* such a record (legacy-adopt.ts); never signalled on its own.
|
|
138
|
+
*/
|
|
139
|
+
pid?: number;
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
/** This process's distribution root, as stamped on its daemon records. */
|
|
@@ -325,6 +354,34 @@ export function updateDaemonContainers(
|
|
|
325
354
|
saveDaemonsFile(getDaemonsFile());
|
|
326
355
|
}
|
|
327
356
|
|
|
357
|
+
/** Record the process group a detached `up` just spawned. */
|
|
358
|
+
export function updateDaemonProcessGroup(
|
|
359
|
+
address: string,
|
|
360
|
+
group: RecordedProcessGroup,
|
|
361
|
+
): void {
|
|
362
|
+
loadDaemonsFromFile();
|
|
363
|
+
const existing = _daemonRegistry.get(address);
|
|
364
|
+
if (!existing) return;
|
|
365
|
+
_daemonRegistry.set(address, {
|
|
366
|
+
...existing,
|
|
367
|
+
pgid: group.pgid,
|
|
368
|
+
pgidStartedAt: group.startedAt,
|
|
369
|
+
});
|
|
370
|
+
saveDaemonsFile(getDaemonsFile());
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Forget a record's process group (it is gone; nothing of it to stop). */
|
|
374
|
+
function clearDaemonProcessGroup(address: string): void {
|
|
375
|
+
loadDaemonsFromFile();
|
|
376
|
+
const existing = _daemonRegistry.get(address);
|
|
377
|
+
if (!existing) return;
|
|
378
|
+
const rest = { ...existing };
|
|
379
|
+
delete rest.pgid;
|
|
380
|
+
delete rest.pgidStartedAt;
|
|
381
|
+
_daemonRegistry.set(address, rest);
|
|
382
|
+
saveDaemonsFile(getDaemonsFile());
|
|
383
|
+
}
|
|
384
|
+
|
|
328
385
|
/**
|
|
329
386
|
* This root's daemons (and unstamped legacy ones). Every root of a product
|
|
330
387
|
* shares one daemons file; another root's records stay in it untouched but
|
|
@@ -573,8 +630,21 @@ export function adoptRunningContainers(
|
|
|
573
630
|
export function claimHealthyService(
|
|
574
631
|
resolved: ResolvedService,
|
|
575
632
|
workspace: string,
|
|
576
|
-
|
|
577
|
-
|
|
633
|
+
onLine: (line: string) => void = () => {},
|
|
634
|
+
): "attached" | "adopted" | "untracked" | "conflict" | "busy" {
|
|
635
|
+
// A compose stack's primary service: its owner is recorded with the
|
|
636
|
+
// stack's project (a shared stack namespace's refcount, stacks/owners.ts).
|
|
637
|
+
// `conflict`: the running project is another checkout's; the caller runs
|
|
638
|
+
// the real up, which refuses it.
|
|
639
|
+
const stack = claimHealthyStack(resolved, workspace);
|
|
640
|
+
if (stack) return stack;
|
|
641
|
+
// A record from before 0.3.0-dev.37 is settled (adopted or dropped)
|
|
642
|
+
// before anything is decided on it (0.3.0-dev.39, a consumer's dev.38
|
|
643
|
+
// finding: the healthy-skip attached to it and it never gained a group).
|
|
644
|
+
const settled = settleDaemonRecord(resolved, workspace, onLine);
|
|
645
|
+
// Dropped with its port held from outside the repo: said by the settle.
|
|
646
|
+
if (settled.outcome === "busy") return "busy";
|
|
647
|
+
const existing = settled.record;
|
|
578
648
|
if (existing) {
|
|
579
649
|
if (!sameDaemonIdentity(existing, resolved.repoPath, resolved.branch)) {
|
|
580
650
|
// Healthy, but the record describes a different checkout — leave the
|
|
@@ -584,9 +654,17 @@ export function claimHealthyService(
|
|
|
584
654
|
attachDaemonOwner(resolved.address, workspace);
|
|
585
655
|
return "attached";
|
|
586
656
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
657
|
+
if (adoptRunningContainers(resolved, workspace).length > 0) return "adopted";
|
|
658
|
+
// Healthy with no record: whose process answers on the service's port?
|
|
659
|
+
// One in the repo is the service (adopted, so `down` can stop it); one
|
|
660
|
+
// from elsewhere is not, and is said, never claimed or touched.
|
|
661
|
+
const holder = settleUntrackedListener(
|
|
662
|
+
resolved,
|
|
663
|
+
workspace,
|
|
664
|
+
onLine,
|
|
665
|
+
`${resolved.address} not started`,
|
|
666
|
+
);
|
|
667
|
+
return holder ?? "untracked";
|
|
590
668
|
}
|
|
591
669
|
|
|
592
670
|
/**
|
|
@@ -644,14 +722,78 @@ export function findDaemonConflict(
|
|
|
644
722
|
}
|
|
645
723
|
|
|
646
724
|
function canResolveDaemonAddress(d: DaemonRecord): boolean {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
725
|
+
return resolvedRepoPathsOf(d).length > 0;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* This root's records whose address resolves in no active workspace: what
|
|
730
|
+
* `down -a` (every workspace) cannot reach, and so warns about and leaves
|
|
731
|
+
* alone (0.3.0-dev.39).
|
|
732
|
+
*/
|
|
733
|
+
export function daemonsOutsideWorkspaces(): DaemonRecord[] {
|
|
734
|
+
return listDaemons().filter((d) => !canResolveDaemonAddress(d));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Every checkout a daemon's address resolves to now: through the focus,
|
|
739
|
+
* each workspace that owns it, then every other active workspace (the
|
|
740
|
+
* same order canResolveDaemonAddress and the reaper use). Empty when the
|
|
741
|
+
* address no longer resolves anywhere.
|
|
742
|
+
*/
|
|
743
|
+
function resolvedRepoPathsOf(d: DaemonRecord): string[] {
|
|
744
|
+
const paths = new Set<string>();
|
|
745
|
+
const add = (r: ResolvedService | null) => {
|
|
746
|
+
if (r) paths.add(r.repoPath);
|
|
747
|
+
};
|
|
748
|
+
add(resolveService(d.address));
|
|
749
|
+
for (const w of new Set([...(d.workspaces ?? []), ...getActiveWorkspaces()]))
|
|
750
|
+
add(resolveService(d.address, w));
|
|
751
|
+
return [...paths];
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Why a daemon record is stale whatever its address resolves to, or null. */
|
|
755
|
+
export type DaemonStaleReason = "repo-path-missing" | "relocated";
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* A record that describes nothing that can be running (0.3.0-dev.33, a
|
|
759
|
+
* consumer's dogfood finding): a record whose repo was relocated kept its
|
|
760
|
+
* Runs tab forever, because its address still resolved and, with no
|
|
761
|
+
* containers captured, nothing could prove it dead.
|
|
762
|
+
*
|
|
763
|
+
* - "repo-path-missing": its repoPath is not on disk. Nothing of it can
|
|
764
|
+
* run from a checkout that does not exist, whichever root wrote it.
|
|
765
|
+
* - "relocated": the address resolves now, but never to its repoPath, and
|
|
766
|
+
* nothing of it is live: none of its candidate containers (captured, or
|
|
767
|
+
* the dev-op's at its old path) is running. The repo name is not a
|
|
768
|
+
* candidate here: the relocated checkout's own container carries it.
|
|
769
|
+
* Records carry no pid, so there is no process to check. Needs the
|
|
770
|
+
* container listing when it has candidates (null: unverifiable, kept).
|
|
771
|
+
*
|
|
772
|
+
* Another root's record is never this root's call (null). A record that
|
|
773
|
+
* resolves to its own repoPath is never stale here: it may be a live
|
|
774
|
+
* non-opctl process, and isDaemonVerifiablyDead keeps that rule.
|
|
775
|
+
*/
|
|
776
|
+
export function daemonStaleReason(
|
|
777
|
+
d: DaemonRecord,
|
|
778
|
+
running: Set<string> | null,
|
|
779
|
+
deps: {
|
|
780
|
+
exists?: (p: string) => boolean;
|
|
781
|
+
resolvedPaths?: (d: DaemonRecord) => string[];
|
|
782
|
+
devOpNames?: (repoPath: string) => string[];
|
|
783
|
+
} = {},
|
|
784
|
+
): DaemonStaleReason | null {
|
|
785
|
+
if (daemonRootOf(d) === "other") return null;
|
|
786
|
+
const exists = deps.exists ?? fs.existsSync;
|
|
787
|
+
if (!d.repoPath || !exists(d.repoPath)) return "repo-path-missing";
|
|
788
|
+
const now = (deps.resolvedPaths ?? resolvedRepoPathsOf)(d);
|
|
789
|
+
if (now.length === 0 || now.includes(d.repoPath)) return null;
|
|
790
|
+
const candidates = [
|
|
791
|
+
...(d.containers ?? []),
|
|
792
|
+
...(deps.devOpNames ?? devOpContainerNames)(d.repoPath),
|
|
793
|
+
].filter(Boolean);
|
|
794
|
+
if (candidates.length === 0) return "relocated";
|
|
795
|
+
if (!running) return null;
|
|
796
|
+
return candidates.some((c) => running.has(c)) ? null : "relocated";
|
|
655
797
|
}
|
|
656
798
|
|
|
657
799
|
/**
|
|
@@ -691,6 +833,9 @@ export function isDaemonVerifiablyDead(
|
|
|
691
833
|
* - it is opctl-based (has captured containers or a discoverable dev-op) and
|
|
692
834
|
* NONE of its candidate containers are currently running.
|
|
693
835
|
*
|
|
836
|
+
* - its checkout is gone, or the service now resolves to another checkout
|
|
837
|
+
* and nothing of the record's is running (daemonStaleReason).
|
|
838
|
+
*
|
|
694
839
|
* Daemons with no discoverable opctl containers are KEPT — we can't verify them
|
|
695
840
|
* via `opctl container ls`, so we err toward keeping a possibly-live process
|
|
696
841
|
* (e.g. a non-opctl dev server). Health is deliberately NOT used: a service's
|
|
@@ -714,10 +859,14 @@ export function pruneDeadDaemons(): string[] {
|
|
|
714
859
|
export async function pruneDeadDaemonsAsync(): Promise<string[]> {
|
|
715
860
|
const daemons = listDaemons();
|
|
716
861
|
if (daemons.length === 0) return [];
|
|
717
|
-
|
|
862
|
+
return pruneAgainst(daemons, await runningContainerBasesAsync());
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** The running opctl containers' base names, or null without opctl. */
|
|
866
|
+
export async function runningContainerBasesAsync(): Promise<Set<string> | null> {
|
|
867
|
+
return (await isOpctlAvailableAsync())
|
|
718
868
|
? new Set((await listRunningOpctlContainersAsync()).map((c) => c.base))
|
|
719
869
|
: null;
|
|
720
|
-
return pruneAgainst(daemons, running);
|
|
721
870
|
}
|
|
722
871
|
|
|
723
872
|
function pruneAgainst(
|
|
@@ -738,6 +887,13 @@ function pruneAgainst(
|
|
|
738
887
|
pruned.push(d.address);
|
|
739
888
|
continue;
|
|
740
889
|
}
|
|
890
|
+
// A checkout that is gone, or one the service has moved away from
|
|
891
|
+
// with nothing of it still running (here or unstamped).
|
|
892
|
+
if (daemonStaleReason(d, running)) {
|
|
893
|
+
unregisterDaemon(d.address);
|
|
894
|
+
pruned.push(d.address);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
741
897
|
if (isDaemonVerifiablyDead(d, running)) {
|
|
742
898
|
unregisterDaemon(d.address);
|
|
743
899
|
pruned.push(d.address);
|
|
@@ -1112,17 +1268,22 @@ function opctlDownBasesFromCommand(cmd: string): string[] {
|
|
|
1112
1268
|
return bases;
|
|
1113
1269
|
}
|
|
1114
1270
|
|
|
1271
|
+
/**
|
|
1272
|
+
* Whether a service command runs detached (a daemon whose log is tailed)
|
|
1273
|
+
* or to completion. Only the commands that start a service, up and restart
|
|
1274
|
+
* (which ends with a long-running up), detach by default; a restart that
|
|
1275
|
+
* finishes on its own says `detachable: false`. Every other command is
|
|
1276
|
+
* one-shot unless it says `detachable: true` (0.3.0-dev.40, DOC-1: every
|
|
1277
|
+
* string command but down and setup detached, so a preflight run from the
|
|
1278
|
+
* Services page never finished). down and setup never detach.
|
|
1279
|
+
*/
|
|
1115
1280
|
export function shouldDetachServiceCommand(
|
|
1116
1281
|
command: string,
|
|
1117
1282
|
cmdDef: CommandDef,
|
|
1118
1283
|
): boolean {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
? true
|
|
1123
|
-
: // restart ends with a long-running up, so it detaches unless the
|
|
1124
|
-
// command says it finishes on its own (`detachable: false`).
|
|
1125
|
-
(cmdDef.detachable ?? true);
|
|
1284
|
+
if (command === "down" || command === "setup") return false;
|
|
1285
|
+
const starts = command === "up" || command === "restart";
|
|
1286
|
+
return typeof cmdDef === "string" ? starts : (cmdDef.detachable ?? starts);
|
|
1126
1287
|
}
|
|
1127
1288
|
|
|
1128
1289
|
export function isInteractiveServiceCommand(cmdDef: CommandDef): boolean {
|
|
@@ -1175,6 +1336,356 @@ export interface RunCommandOptions {
|
|
|
1175
1336
|
* declares secret_env is refused unless every key is here.
|
|
1176
1337
|
*/
|
|
1177
1338
|
secretEnv?: Record<string, string>;
|
|
1339
|
+
/**
|
|
1340
|
+
* For `down`: whether the caller named this service (default true). When
|
|
1341
|
+
* down cannot stop a service it knows is running it warns; an explicit
|
|
1342
|
+
* down then exits non-zero, a workspace-wide one does not.
|
|
1343
|
+
*/
|
|
1344
|
+
explicit?: boolean;
|
|
1345
|
+
/** For `down`: what it found to stop (see DownOutcome). */
|
|
1346
|
+
onDownOutcome?: (outcome: DownOutcome) => void;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* What a `down` of a service with no down command and no container did, for
|
|
1351
|
+
* callers that report it: it "stopped" the process group `up` recorded,
|
|
1352
|
+
* found it "already-exited", had "nothing-recorded" to stop, or could not
|
|
1353
|
+
* stop what it knows is running ("unstoppable").
|
|
1354
|
+
*/
|
|
1355
|
+
export type DownOutcome =
|
|
1356
|
+
| "stopped"
|
|
1357
|
+
| "already-exited"
|
|
1358
|
+
| "nothing-recorded"
|
|
1359
|
+
| "unstoppable";
|
|
1360
|
+
|
|
1361
|
+
/** SIGTERM-to-SIGKILL grace for a recorded process group. */
|
|
1362
|
+
function downGraceMs(): number {
|
|
1363
|
+
const raw = process.env.DEN_DOWN_GRACE_MS;
|
|
1364
|
+
const ms = raw === undefined ? NaN : Number(raw);
|
|
1365
|
+
return Number.isFinite(ms) && ms >= 0 ? ms : 3000;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
type GroupRecord = DaemonRecord & { pgid: number; pgidStartedAt: string };
|
|
1369
|
+
|
|
1370
|
+
/** Whether a record carries a process group `down` can verify and signal. */
|
|
1371
|
+
function hasProcessGroup(d: DaemonRecord | undefined): d is GroupRecord {
|
|
1372
|
+
return !!d && typeof d.pgid === "number" && !!d.pgidStartedAt;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Stop the process group a detached `up` recorded for this daemon (SIGTERM,
|
|
1377
|
+
* grace, SIGKILL; see process-group.ts), say what happened on `onLine`, and
|
|
1378
|
+
* unregister the record once nothing of it runs.
|
|
1379
|
+
*/
|
|
1380
|
+
async function stopDaemonProcessGroup(
|
|
1381
|
+
record: GroupRecord,
|
|
1382
|
+
onLine: (line: string) => void,
|
|
1383
|
+
): Promise<DownOutcome> {
|
|
1384
|
+
const result = await stopProcessGroup(
|
|
1385
|
+
{ pgid: record.pgid, startedAt: record.pgidStartedAt },
|
|
1386
|
+
downGraceMs(),
|
|
1387
|
+
);
|
|
1388
|
+
switch (result.outcome) {
|
|
1389
|
+
case "stopped":
|
|
1390
|
+
onLine(
|
|
1391
|
+
`stopped ${record.address}: process group ${record.pgid} (${result.signal})`,
|
|
1392
|
+
);
|
|
1393
|
+
unregisterDaemon(record.address);
|
|
1394
|
+
return "stopped";
|
|
1395
|
+
case "gone":
|
|
1396
|
+
onLine(
|
|
1397
|
+
`${record.address} had already exited (process group ${record.pgid})`,
|
|
1398
|
+
);
|
|
1399
|
+
unregisterDaemon(record.address);
|
|
1400
|
+
return "already-exited";
|
|
1401
|
+
case "reused":
|
|
1402
|
+
// The recorded process exited; whatever holds its pid now is not ours.
|
|
1403
|
+
onLine(
|
|
1404
|
+
`${record.address} had already exited: ${result.detail}; left it alone`,
|
|
1405
|
+
);
|
|
1406
|
+
unregisterDaemon(record.address);
|
|
1407
|
+
return "already-exited";
|
|
1408
|
+
case "survived":
|
|
1409
|
+
onLine(
|
|
1410
|
+
`Warning: could not stop ${record.address}: process group ${record.pgid} survived SIGKILL (pids ${result.members.join(", ")})`,
|
|
1411
|
+
);
|
|
1412
|
+
return "unstoppable";
|
|
1413
|
+
case "error":
|
|
1414
|
+
onLine(`Warning: could not stop ${record.address}: ${result.detail}`);
|
|
1415
|
+
return "unstoppable";
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* The daemon records of `repo`'s own services (`<repo>/<svc>`), this root's
|
|
1421
|
+
* or unstamped, other than `except`: what a repo-level down stops.
|
|
1422
|
+
*/
|
|
1423
|
+
function repoServiceRecords(repo: string, except: string): DaemonRecord[] {
|
|
1424
|
+
return listDaemons().filter(
|
|
1425
|
+
(d) =>
|
|
1426
|
+
d.address !== except &&
|
|
1427
|
+
d.address.startsWith(`${repo}/`) &&
|
|
1428
|
+
daemonRootOf(d) !== "other",
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** Which of two down outcomes says more: what a fanned-out down reports. */
|
|
1433
|
+
const DOWN_RANK: Record<DownOutcome, number> = {
|
|
1434
|
+
"nothing-recorded": 0,
|
|
1435
|
+
"already-exited": 1,
|
|
1436
|
+
stopped: 2,
|
|
1437
|
+
unstoppable: 3,
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Down each address in turn (each through runServiceCommand's own path:
|
|
1442
|
+
* its refcount, its down command or its recorded process group), lines to
|
|
1443
|
+
* the caller's onLine. Resolves the outcome that says most and the worst
|
|
1444
|
+
* exit code.
|
|
1445
|
+
*/
|
|
1446
|
+
async function downEach(
|
|
1447
|
+
addresses: string[],
|
|
1448
|
+
options: RunCommandOptions,
|
|
1449
|
+
): Promise<{ outcome: DownOutcome; exitCode: number }> {
|
|
1450
|
+
let outcome: DownOutcome = "nothing-recorded";
|
|
1451
|
+
let exitCode = 0;
|
|
1452
|
+
for (const address of addresses) {
|
|
1453
|
+
let own: DownOutcome | null = null;
|
|
1454
|
+
const code = await new Promise<number>((resolve) => {
|
|
1455
|
+
runServiceCommand({
|
|
1456
|
+
...options,
|
|
1457
|
+
address,
|
|
1458
|
+
command: "down",
|
|
1459
|
+
onDownOutcome: (o) => {
|
|
1460
|
+
own = o;
|
|
1461
|
+
},
|
|
1462
|
+
onClose: resolve,
|
|
1463
|
+
});
|
|
1464
|
+
});
|
|
1465
|
+
// A service with a down command of its own reports no outcome: a
|
|
1466
|
+
// success is a stop.
|
|
1467
|
+
const o: DownOutcome = own ?? (code === 0 ? "stopped" : "unstoppable");
|
|
1468
|
+
if (DOWN_RANK[o] > DOWN_RANK[outcome]) outcome = o;
|
|
1469
|
+
exitCode = Math.max(exitCode, code);
|
|
1470
|
+
}
|
|
1471
|
+
return { outcome, exitCode };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* A record `up` and `down` can't verify by its process group: none was
|
|
1476
|
+
* recorded (before 0.3.0-dev.37) and it names no container of its own.
|
|
1477
|
+
*/
|
|
1478
|
+
function isLegacyRecord(d: DaemonRecord): boolean {
|
|
1479
|
+
return (
|
|
1480
|
+
!hasProcessGroup(d) &&
|
|
1481
|
+
!(d.containers?.length ?? 0) &&
|
|
1482
|
+
devOpContainerNames(d.repoPath).length === 0
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* The daemon record for `resolved`, settled: a record from before
|
|
1488
|
+
* 0.3.0-dev.37 (no process group, nothing of its own to verify) is adopted
|
|
1489
|
+
* or dropped here, before any caller decides on it (0.3.0-dev.39). Every
|
|
1490
|
+
* up, restart, down and healthy-skip reads its record through this.
|
|
1491
|
+
*/
|
|
1492
|
+
export function settledDaemonRecord(
|
|
1493
|
+
resolved: ResolvedService,
|
|
1494
|
+
workspace: string,
|
|
1495
|
+
onLine: (line: string) => void,
|
|
1496
|
+
): DaemonRecord | undefined {
|
|
1497
|
+
return settleDaemonRecord(resolved, workspace, onLine).record;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** settledDaemonRecord, with what the settle did (null: nothing to settle). */
|
|
1501
|
+
function settleDaemonRecord(
|
|
1502
|
+
resolved: ResolvedService,
|
|
1503
|
+
workspace: string,
|
|
1504
|
+
onLine: (line: string) => void,
|
|
1505
|
+
): {
|
|
1506
|
+
record: DaemonRecord | undefined;
|
|
1507
|
+
outcome: "adopted" | "dropped" | "busy" | null;
|
|
1508
|
+
} {
|
|
1509
|
+
const record = findDaemonByAddress(resolved.address);
|
|
1510
|
+
if (!record || !isLegacyRecord(record)) return { record, outcome: null };
|
|
1511
|
+
const outcome = settleLegacyRecord(record, resolved, workspace, onLine);
|
|
1512
|
+
return { record: findDaemonByAddress(resolved.address), outcome };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** The service's known port and configured up command (legacy-adopt.ts). */
|
|
1516
|
+
function serviceProcessFacts(
|
|
1517
|
+
resolved: ResolvedService,
|
|
1518
|
+
workspace: string,
|
|
1519
|
+
): { port: number | null; upCommand: string | null; plain: boolean } {
|
|
1520
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1521
|
+
const svc = manifest?.services?.[resolved.subsystem] as
|
|
1522
|
+
| { port?: unknown; health?: { local?: { url?: string } } }
|
|
1523
|
+
| undefined;
|
|
1524
|
+
const upDef = manifest
|
|
1525
|
+
? effectiveLocalCommands(
|
|
1526
|
+
manifest,
|
|
1527
|
+
resolved.subsystem,
|
|
1528
|
+
resolved.repoName,
|
|
1529
|
+
workspace,
|
|
1530
|
+
)["up"]
|
|
1531
|
+
: undefined;
|
|
1532
|
+
const upCommand = upDef ? commandDefToString(upDef) : null;
|
|
1533
|
+
const port = servicePort({
|
|
1534
|
+
port: svc?.port,
|
|
1535
|
+
healthUrl: svc?.health?.local?.url ?? null,
|
|
1536
|
+
upCommand,
|
|
1537
|
+
});
|
|
1538
|
+
// A plain process: an up of its own, no compose stack and no dev op. A
|
|
1539
|
+
// container's port is held by the container runtime, never by the repo.
|
|
1540
|
+
const plain =
|
|
1541
|
+
!!upCommand &&
|
|
1542
|
+
!manifest?.compose &&
|
|
1543
|
+
devOpContainerNames(resolved.repoPath).length === 0;
|
|
1544
|
+
return { port, upCommand, plain };
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* A service with no record whose port is already taken (a plain process
|
|
1549
|
+
* service only): a listener whose cwd is in the repo is the service,
|
|
1550
|
+
* adopted with its process group ("adopted"); one from elsewhere is
|
|
1551
|
+
* reported and left alone ("busy"). Null when the port is free or unknown.
|
|
1552
|
+
*/
|
|
1553
|
+
function settleUntrackedListener(
|
|
1554
|
+
resolved: ResolvedService,
|
|
1555
|
+
workspace: string,
|
|
1556
|
+
onLine: (line: string) => void,
|
|
1557
|
+
/** What the caller does about a busy port, said after the warning. */
|
|
1558
|
+
then: string,
|
|
1559
|
+
): "adopted" | "busy" | null {
|
|
1560
|
+
const facts = serviceProcessFacts(resolved, workspace);
|
|
1561
|
+
if (!facts.plain || !facts.port) return null;
|
|
1562
|
+
const verdict = adoptLegacyProcess({
|
|
1563
|
+
repoPath: resolved.repoPath,
|
|
1564
|
+
port: facts.port,
|
|
1565
|
+
upCommand: null,
|
|
1566
|
+
});
|
|
1567
|
+
if (verdict.kind === "adopted") {
|
|
1568
|
+
registerDaemon({
|
|
1569
|
+
address: resolved.address,
|
|
1570
|
+
repoName: resolved.repoName,
|
|
1571
|
+
subsystem: resolved.subsystem,
|
|
1572
|
+
repoPath: resolved.repoPath,
|
|
1573
|
+
branch: resolved.branch,
|
|
1574
|
+
workspaces: [workspace],
|
|
1575
|
+
logPath: getServiceLogPath(resolved.repoName, resolved.subsystem),
|
|
1576
|
+
startedAt: new Date(),
|
|
1577
|
+
containers: [],
|
|
1578
|
+
});
|
|
1579
|
+
updateDaemonProcessGroup(resolved.address, verdict.group);
|
|
1580
|
+
onLine(
|
|
1581
|
+
`adopted the running process (pid ${verdict.pid}, port ${facts.port}) for ${resolved.address}: it runs in ${resolved.repoPath}`,
|
|
1582
|
+
);
|
|
1583
|
+
return "adopted";
|
|
1584
|
+
}
|
|
1585
|
+
if (!verdict.busy) return null;
|
|
1586
|
+
onLine(
|
|
1587
|
+
`Warning: ${busyListenerText(verdict.busy, resolved.repoPath, resolved.address)}; ${then}`,
|
|
1588
|
+
);
|
|
1589
|
+
return "busy";
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* What a record from before 0.3.0-dev.37 means now (legacy-adopt.ts), for
|
|
1594
|
+
* `up` and `down` alike: "adopted" (the running process is the service; its
|
|
1595
|
+
* group is written into the record, so it is `down`'s to stop) or "dropped"
|
|
1596
|
+
* (stale: warned, the record removed, nothing signalled). The port is the
|
|
1597
|
+
* service's known one (servicePort: `port`, its health URL, `--port` in its
|
|
1598
|
+
* up command); the token match uses its configured up command.
|
|
1599
|
+
*/
|
|
1600
|
+
function settleLegacyRecord(
|
|
1601
|
+
record: DaemonRecord,
|
|
1602
|
+
resolved: ResolvedService,
|
|
1603
|
+
workspace: string,
|
|
1604
|
+
onLine: (line: string) => void,
|
|
1605
|
+
): "adopted" | "dropped" | "busy" {
|
|
1606
|
+
const { port, upCommand } = serviceProcessFacts(resolved, workspace);
|
|
1607
|
+
const verdict = adoptLegacyProcess({
|
|
1608
|
+
repoPath: record.repoPath,
|
|
1609
|
+
pid: typeof record.pid === "number" ? record.pid : undefined,
|
|
1610
|
+
port,
|
|
1611
|
+
upCommand,
|
|
1612
|
+
});
|
|
1613
|
+
if (verdict.kind === "adopted") {
|
|
1614
|
+
updateDaemonProcessGroup(record.address, verdict.group);
|
|
1615
|
+
onLine(
|
|
1616
|
+
`adopted pre-dev.37 process (pid ${verdict.pid}` +
|
|
1617
|
+
(verdict.port ? `, port ${verdict.port}` : "") +
|
|
1618
|
+
`) for ${record.address}`,
|
|
1619
|
+
);
|
|
1620
|
+
return "adopted";
|
|
1621
|
+
}
|
|
1622
|
+
onLine(
|
|
1623
|
+
`Warning: ${record.address} was recorded before 0.3.0-dev.37 and ` +
|
|
1624
|
+
(verdict.busy
|
|
1625
|
+
? "its port is held by a process that is not its up"
|
|
1626
|
+
: `nothing running is it (no process on its port in ${record.repoPath}` +
|
|
1627
|
+
(typeof record.pid === "number"
|
|
1628
|
+
? `, pid ${record.pid} not its up`
|
|
1629
|
+
: "") +
|
|
1630
|
+
")") +
|
|
1631
|
+
"; dropped the record, nothing was signalled",
|
|
1632
|
+
);
|
|
1633
|
+
if (verdict.busy)
|
|
1634
|
+
onLine(
|
|
1635
|
+
`Warning: ${busyListenerText(verdict.busy, record.repoPath, record.address)}`,
|
|
1636
|
+
);
|
|
1637
|
+
unregisterDaemon(record.address);
|
|
1638
|
+
return verdict.busy ? "busy" : "dropped";
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* A restart with no `restart` of its own: the service's down (its declared
|
|
1643
|
+
* down, then the recorded group, as `down` does; forced, since a restart
|
|
1644
|
+
* brings it back for every owner), then its up, detached, as `up` does. The
|
|
1645
|
+
* returned proc stands for the up's streaming tail: killing it stops the
|
|
1646
|
+
* tail (now, or as soon as the up starts), never the service.
|
|
1647
|
+
*/
|
|
1648
|
+
function restartAsDownThenUp(
|
|
1649
|
+
options: RunCommandOptions,
|
|
1650
|
+
logPath: string | undefined,
|
|
1651
|
+
): RunCommandResult {
|
|
1652
|
+
let tail: ChildProcess | null = null;
|
|
1653
|
+
let stopTail = false;
|
|
1654
|
+
const proxy = new EventEmitter() as unknown as ChildProcess;
|
|
1655
|
+
(proxy as unknown as { kill: (s?: NodeJS.Signals) => boolean }).kill = (
|
|
1656
|
+
signal,
|
|
1657
|
+
) => {
|
|
1658
|
+
stopTail = true;
|
|
1659
|
+
try {
|
|
1660
|
+
tail?.kill?.(signal);
|
|
1661
|
+
} catch {
|
|
1662
|
+
/* already gone */
|
|
1663
|
+
}
|
|
1664
|
+
return true;
|
|
1665
|
+
};
|
|
1666
|
+
runServiceCommand({
|
|
1667
|
+
...options,
|
|
1668
|
+
command: "down",
|
|
1669
|
+
force: true,
|
|
1670
|
+
onDownOutcome: undefined,
|
|
1671
|
+
onServiceExit: undefined,
|
|
1672
|
+
onClose: (code) => {
|
|
1673
|
+
if (code !== 0) {
|
|
1674
|
+
options.onClose(code);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const up = runServiceCommand({ ...options, command: "up" });
|
|
1678
|
+
tail = up.proc;
|
|
1679
|
+
if (stopTail) {
|
|
1680
|
+
try {
|
|
1681
|
+
tail?.kill?.("SIGTERM");
|
|
1682
|
+
} catch {
|
|
1683
|
+
/* already gone */
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
},
|
|
1687
|
+
});
|
|
1688
|
+
return { proc: proxy, detachable: true, interactive: false, logPath };
|
|
1178
1689
|
}
|
|
1179
1690
|
|
|
1180
1691
|
export interface RunCommandResult {
|
|
@@ -1216,6 +1727,18 @@ function repoStartedFallback(
|
|
|
1216
1727
|
if (!repoEntry || repoEntry.subsystem === resolved.subsystem) return null;
|
|
1217
1728
|
const manifest = readManifest(resolved.repoPath);
|
|
1218
1729
|
if (!manifest) return null;
|
|
1730
|
+
// A service with an up of its own starts itself, so its down is its own
|
|
1731
|
+
// too: the process group that up recorded (0.3.0-dev.39, a consumer's
|
|
1732
|
+
// dev.38 finding: `down repo/api` pointed at the repo's down instead).
|
|
1733
|
+
if (
|
|
1734
|
+
effectiveLocalCommands(
|
|
1735
|
+
manifest,
|
|
1736
|
+
resolved.subsystem,
|
|
1737
|
+
resolved.repoName,
|
|
1738
|
+
workspace ?? "default",
|
|
1739
|
+
)["up"]
|
|
1740
|
+
)
|
|
1741
|
+
return null;
|
|
1219
1742
|
const repoCommands = effectiveLocalCommands(
|
|
1220
1743
|
manifest,
|
|
1221
1744
|
repoEntry.subsystem,
|
|
@@ -1345,11 +1868,64 @@ export async function prepareServiceSecretEnv(
|
|
|
1345
1868
|
|
|
1346
1869
|
/** What a run of `command` on `address` would do, without running it. */
|
|
1347
1870
|
export type ServiceCommandPreview =
|
|
1348
|
-
| { kind: "run"; address: string }
|
|
1871
|
+
| { kind: "run"; address: string; note?: string }
|
|
1349
1872
|
| { kind: "via-repo"; address: string; note: string }
|
|
1350
1873
|
| { kind: "nothing"; address: string; note: string }
|
|
1351
1874
|
| { kind: "error"; address: string; note: string };
|
|
1352
1875
|
|
|
1876
|
+
/**
|
|
1877
|
+
* What a real `up` of a service with an up of its own does first about its
|
|
1878
|
+
* record and port (settleDaemonRecord, settleUntrackedListener), said for a
|
|
1879
|
+
* dry run (0.3.0-dev.40, a consumer's dev.39 nit: it said only "Would up").
|
|
1880
|
+
* It only looks: nothing is signalled and the record is not written. Null
|
|
1881
|
+
* when there is nothing to settle (the up just runs). Only for a dry run's
|
|
1882
|
+
* words: up-targets reads previewServiceCommand, which this leaves alone.
|
|
1883
|
+
*/
|
|
1884
|
+
export function previewUpSettle(
|
|
1885
|
+
address: string,
|
|
1886
|
+
workspace?: string,
|
|
1887
|
+
): ServiceCommandPreview | null {
|
|
1888
|
+
const resolved = resolveService(address, workspace);
|
|
1889
|
+
if (!resolved) return null;
|
|
1890
|
+
const record = findDaemonByAddress(resolved.address);
|
|
1891
|
+
const legacy = !!record && isLegacyRecord(record);
|
|
1892
|
+
if (record && !legacy) return null;
|
|
1893
|
+
const facts = serviceProcessFacts(resolved, workspace ?? "default");
|
|
1894
|
+
if (!facts.upCommand) return null;
|
|
1895
|
+
if (!legacy && (!facts.plain || !facts.port)) return null;
|
|
1896
|
+
const verdict = adoptLegacyProcess({
|
|
1897
|
+
repoPath: resolved.repoPath,
|
|
1898
|
+
pid: legacy && typeof record.pid === "number" ? record.pid : undefined,
|
|
1899
|
+
port: facts.port,
|
|
1900
|
+
upCommand: legacy ? facts.upCommand : null,
|
|
1901
|
+
});
|
|
1902
|
+
const what = legacy ? "a pre-dev.37 record" : "no record";
|
|
1903
|
+
if (verdict.kind === "adopted")
|
|
1904
|
+
return {
|
|
1905
|
+
kind: "nothing",
|
|
1906
|
+
address,
|
|
1907
|
+
note:
|
|
1908
|
+
`${resolved.address} (${what}): would adopt pid ${verdict.pid}` +
|
|
1909
|
+
(verdict.port ? ` (port ${verdict.port})` : "") +
|
|
1910
|
+
", nothing started",
|
|
1911
|
+
};
|
|
1912
|
+
if (verdict.busy)
|
|
1913
|
+
return {
|
|
1914
|
+
kind: "error",
|
|
1915
|
+
address,
|
|
1916
|
+
note:
|
|
1917
|
+
verdict.busy.reason === "outside"
|
|
1918
|
+
? `${resolved.address} (${what}): port ${verdict.busy.port} held outside the repo: would not start`
|
|
1919
|
+
: `${resolved.address} (${what}): port ${verdict.busy.port} held by pid ${verdict.busy.pid} in Den's own process group: would not start`,
|
|
1920
|
+
};
|
|
1921
|
+
if (!legacy) return null;
|
|
1922
|
+
return {
|
|
1923
|
+
kind: "run",
|
|
1924
|
+
address,
|
|
1925
|
+
note: `${resolved.address} (${what}): would drop the record (nothing running), then start it`,
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1353
1929
|
/**
|
|
1354
1930
|
* The dry-run twin of runServiceCommand's command lookup: the same
|
|
1355
1931
|
* resolution, own-command, restart-synthesis and repo-started rules, so a
|
|
@@ -1382,7 +1958,7 @@ export function previewServiceCommand(
|
|
|
1382
1958
|
workspace ?? "default",
|
|
1383
1959
|
)
|
|
1384
1960
|
: {};
|
|
1385
|
-
if (own[command] || (command === "restart" && own["
|
|
1961
|
+
if (own[command] || (command === "restart" && own["up"]))
|
|
1386
1962
|
return { kind: "run", address };
|
|
1387
1963
|
const viaRepo = repoStartedFallback(resolved, command, workspace);
|
|
1388
1964
|
if (viaRepo?.address)
|
|
@@ -1391,12 +1967,22 @@ export function previewServiceCommand(
|
|
|
1391
1967
|
return command === "down"
|
|
1392
1968
|
? { kind: "nothing", address, note: viaRepo.note }
|
|
1393
1969
|
: { kind: "error", address, note: viaRepo.note };
|
|
1394
|
-
if (command === "down")
|
|
1970
|
+
if (command === "down") {
|
|
1971
|
+
// The real down stops the process group a detached up recorded.
|
|
1972
|
+
const record = findDaemonByAddress(resolved.address);
|
|
1973
|
+
if (hasProcessGroup(record)) return { kind: "run", address };
|
|
1974
|
+
// A repo-level down stops its services' records.
|
|
1975
|
+
if (
|
|
1976
|
+
!address.includes("/") &&
|
|
1977
|
+
repoServiceRecords(resolved.repoName, resolved.address).length > 0
|
|
1978
|
+
)
|
|
1979
|
+
return { kind: "run", address };
|
|
1395
1980
|
return {
|
|
1396
1981
|
kind: "nothing",
|
|
1397
1982
|
address,
|
|
1398
1983
|
note: "no down command: nothing to stop",
|
|
1399
1984
|
};
|
|
1985
|
+
}
|
|
1400
1986
|
return {
|
|
1401
1987
|
kind: "error",
|
|
1402
1988
|
address,
|
|
@@ -1467,6 +2053,68 @@ export function runServiceCommand(
|
|
|
1467
2053
|
|
|
1468
2054
|
const { repoPath, subsystem } = resolved;
|
|
1469
2055
|
|
|
2056
|
+
const explicit = options.explicit ?? true;
|
|
2057
|
+
const reportDown = (outcome: DownOutcome): void =>
|
|
2058
|
+
options.onDownOutcome?.(outcome);
|
|
2059
|
+
// A down that could not stop what it knows is running: always a warning,
|
|
2060
|
+
// and a failure when the caller named this service.
|
|
2061
|
+
const unstoppableExit = (): number => (explicit ? 1 : 0);
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Down with no down command and no container to stop (0.3.0-dev.37): stop
|
|
2065
|
+
* the process group a detached `up` recorded. With no record there is
|
|
2066
|
+
* nothing to stop. A record without a group (written before dev.37, or by
|
|
2067
|
+
* an adoption) is running as far as Den knows, and Den cannot stop it:
|
|
2068
|
+
* that is said and fails an explicit down instead of reporting a success.
|
|
2069
|
+
* Such a record is dropped, since Den can neither stop nor verify it.
|
|
2070
|
+
*/
|
|
2071
|
+
const downWithoutCommand = (why: string): RunCommandResult => {
|
|
2072
|
+
// A repo-level down (`down <repo>`, and `down -a`, which downs each
|
|
2073
|
+
// repo) with no down of its own stops every service record of the repo
|
|
2074
|
+
// through the same per-record path (0.3.0-dev.38): the mirror of `up`
|
|
2075
|
+
// starting a repo's services (resolveUpTargets).
|
|
2076
|
+
const serviceRecords = address.includes("/")
|
|
2077
|
+
? []
|
|
2078
|
+
: repoServiceRecords(resolved.repoName, resolved.address);
|
|
2079
|
+
if (serviceRecords.length > 0) {
|
|
2080
|
+
void downEach(
|
|
2081
|
+
serviceRecords.map((d) => d.address),
|
|
2082
|
+
options,
|
|
2083
|
+
).then(({ outcome, exitCode }) => {
|
|
2084
|
+
const own = findDaemonByAddress(resolved.address);
|
|
2085
|
+
if (own) unregisterDaemon(own.address);
|
|
2086
|
+
reportDown(outcome);
|
|
2087
|
+
onClose(exitCode);
|
|
2088
|
+
});
|
|
2089
|
+
return dummy();
|
|
2090
|
+
}
|
|
2091
|
+
// A record from before 0.3.0-dev.37: adopt what runs, or drop it.
|
|
2092
|
+
const record = settledDaemonRecord(resolved, workspace, onLine);
|
|
2093
|
+
if (hasProcessGroup(record)) {
|
|
2094
|
+
void stopDaemonProcessGroup(record, onLine).then((outcome) => {
|
|
2095
|
+
reportDown(outcome);
|
|
2096
|
+
onClose(outcome === "unstoppable" ? unstoppableExit() : 0);
|
|
2097
|
+
});
|
|
2098
|
+
return dummy();
|
|
2099
|
+
}
|
|
2100
|
+
if (record && !isDaemonVerifiablyDead(record)) {
|
|
2101
|
+
onLine(
|
|
2102
|
+
`Warning: ${record.address} is recorded as running, but ${product().cli} kept no process for it ` +
|
|
2103
|
+
`(started before 0.3.0-dev.37) and it has no down command; ` +
|
|
2104
|
+
`stop it yourself (${product().cli} has dropped its record)`,
|
|
2105
|
+
);
|
|
2106
|
+
unregisterDaemon(record.address);
|
|
2107
|
+
reportDown("unstoppable");
|
|
2108
|
+
closeAsync(unstoppableExit());
|
|
2109
|
+
return dummy();
|
|
2110
|
+
}
|
|
2111
|
+
if (record) unregisterDaemon(record.address);
|
|
2112
|
+
onLine(`nothing to stop (${why})`);
|
|
2113
|
+
reportDown("nothing-recorded");
|
|
2114
|
+
closeAsync(0);
|
|
2115
|
+
return dummy();
|
|
2116
|
+
};
|
|
2117
|
+
|
|
1470
2118
|
// R13: Den never runs a command on a remote service. A down stops nothing
|
|
1471
2119
|
// here, which is a success; anything else is refused.
|
|
1472
2120
|
const refusal = placementRefusal(
|
|
@@ -1481,6 +2129,22 @@ export function runServiceCommand(
|
|
|
1481
2129
|
return dummy();
|
|
1482
2130
|
}
|
|
1483
2131
|
|
|
2132
|
+
// Every up decision below reads the service's record: one from before
|
|
2133
|
+
// 0.3.0-dev.37 is settled (adopted or dropped, and said) first
|
|
2134
|
+
// (0.3.0-dev.39, a consumer's dev.38 finding). A restart settles in its
|
|
2135
|
+
// down step; a down after its ownership gate, which signals nothing.
|
|
2136
|
+
const upSettle =
|
|
2137
|
+
command === "up" ? settleDaemonRecord(resolved, workspace, onLine) : null;
|
|
2138
|
+
// Dropped with its port held by a process Den won't adopt (said by the
|
|
2139
|
+
// settle): refused before anything is spawned, as a repo-level up refuses
|
|
2140
|
+
// it (0.3.0-dev.40, a consumer's dev.39 finding: the start died on
|
|
2141
|
+
// EADDRINUSE first).
|
|
2142
|
+
if (upSettle?.outcome === "busy") {
|
|
2143
|
+
onLine(`${resolved.address} not started: its port is in use`);
|
|
2144
|
+
closeAsync(1);
|
|
2145
|
+
return dummy();
|
|
2146
|
+
}
|
|
2147
|
+
|
|
1484
2148
|
// ── Up: share-if-identical / hard-block on conflict ─────────────────────────
|
|
1485
2149
|
if (command === "up" || command === "restart") {
|
|
1486
2150
|
let existing = findDaemonByAddress(resolved.address);
|
|
@@ -1541,6 +2205,25 @@ export function runServiceCommand(
|
|
|
1541
2205
|
closeAsync(0);
|
|
1542
2206
|
return dummy();
|
|
1543
2207
|
}
|
|
2208
|
+
} else if (command === "up" && !upSettle?.outcome) {
|
|
2209
|
+
// Nothing recorded, but the port is taken (0.3.0-dev.39). In the
|
|
2210
|
+
// repo: that is the service, adopted, already up. Elsewhere: said
|
|
2211
|
+
// and left alone, and refused before anything is spawned
|
|
2212
|
+
// (0.3.0-dev.40), as a repo-level up refuses it.
|
|
2213
|
+
const holder = settleUntrackedListener(
|
|
2214
|
+
resolved,
|
|
2215
|
+
workspace,
|
|
2216
|
+
onLine,
|
|
2217
|
+
`${resolved.address} not started`,
|
|
2218
|
+
);
|
|
2219
|
+
if (holder === "adopted") {
|
|
2220
|
+
closeAsync(0);
|
|
2221
|
+
return dummy();
|
|
2222
|
+
}
|
|
2223
|
+
if (holder === "busy") {
|
|
2224
|
+
closeAsync(1);
|
|
2225
|
+
return dummy();
|
|
2226
|
+
}
|
|
1544
2227
|
}
|
|
1545
2228
|
}
|
|
1546
2229
|
}
|
|
@@ -1568,6 +2251,9 @@ export function runServiceCommand(
|
|
|
1568
2251
|
return dummy();
|
|
1569
2252
|
}
|
|
1570
2253
|
}
|
|
2254
|
+
// Past the ownership gate, a down stops what it finds: a pre-dev.37 record
|
|
2255
|
+
// is settled first, so its adopted group is stopped (0.3.0-dev.39).
|
|
2256
|
+
if (command === "down") settledDaemonRecord(resolved, workspace, onLine);
|
|
1571
2257
|
|
|
1572
2258
|
// Down via opctl: stop the service's running containers directly, identified
|
|
1573
2259
|
// from live `opctl container ls` ∪ docker `opctl_<base>_<hash>` names — no
|
|
@@ -1654,11 +2340,9 @@ export function runServiceCommand(
|
|
|
1654
2340
|
// there is nothing to stop — success, same contract as the declared-down
|
|
1655
2341
|
// fallthrough below.
|
|
1656
2342
|
if (command === "down") {
|
|
1657
|
-
|
|
1658
|
-
`
|
|
2343
|
+
return downWithoutCommand(
|
|
2344
|
+
`no matching opctl containers; no manifest at ${repoPath}`,
|
|
1659
2345
|
);
|
|
1660
|
-
closeAsync(0);
|
|
1661
|
-
return dummy();
|
|
1662
2346
|
}
|
|
1663
2347
|
onLine(`Error: could not read ${product().manifestYml} in ${repoPath}`);
|
|
1664
2348
|
closeAsync(1);
|
|
@@ -1679,6 +2363,17 @@ export function runServiceCommand(
|
|
|
1679
2363
|
if (!cmdDef && command === "restart") {
|
|
1680
2364
|
const downDef = svcCommands["down"];
|
|
1681
2365
|
const upDef = svcCommands["up"];
|
|
2366
|
+
// A daemon restart is a down step, then a detached up step, never one
|
|
2367
|
+
// `sh -c "<down> && <up>"`: the up's recorded process group must hold
|
|
2368
|
+
// only the up (0.3.0-dev.38). The tab runtime composes its own below.
|
|
2369
|
+
// With no down of its own (0.3.0-dev.39, a consumer's dev.38 finding),
|
|
2370
|
+
// the down step stops what the detached up recorded, as `down` does.
|
|
2371
|
+
if (upDef && (!downDef || runtime !== "tab")) {
|
|
2372
|
+
return restartAsDownThenUp(
|
|
2373
|
+
options,
|
|
2374
|
+
getServiceLogPath(resolved.repoName, resolved.subsystem),
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
1682
2377
|
if (downDef && upDef) {
|
|
1683
2378
|
const downCmd = rewriteLegacyOpctlKillCommand(
|
|
1684
2379
|
"down",
|
|
@@ -1706,9 +2401,7 @@ export function runServiceCommand(
|
|
|
1706
2401
|
// successful down, not an error (keeps the ⋮ menu / workspace Down safe to
|
|
1707
2402
|
// offer on every system).
|
|
1708
2403
|
if (command === "down") {
|
|
1709
|
-
|
|
1710
|
-
closeAsync(0);
|
|
1711
|
-
return dummy();
|
|
2404
|
+
return downWithoutCommand("no running opctl containers, no down command");
|
|
1712
2405
|
}
|
|
1713
2406
|
onLine(
|
|
1714
2407
|
`Error: command "${command}" not found for service "${address}" (subsystem "${subsystem}") in local environment`,
|
|
@@ -1964,6 +2657,17 @@ export function runServiceCommand(
|
|
|
1964
2657
|
// "nothing left to stop" is an absence of evidence, not evidence the
|
|
1965
2658
|
// teardown worked — reporting ✔ there would hide every real failure.
|
|
1966
2659
|
const effective = exitCode === 0 || sweptClean ? 0 : exitCode;
|
|
2660
|
+
// The down command ran; what the detached up started may still run
|
|
2661
|
+
// (the down stopped its containers, not the process that started
|
|
2662
|
+
// them). Stop the recorded group too (0.3.0-dev.37).
|
|
2663
|
+
const record = findDaemonByAddress(resolved.address);
|
|
2664
|
+
if (effective === 0 && hasProcessGroup(record)) {
|
|
2665
|
+
void stopDaemonProcessGroup(record, onLine).then((outcome) => {
|
|
2666
|
+
closeExistingTab();
|
|
2667
|
+
onClose(outcome === "unstoppable" ? unstoppableExit() : 0);
|
|
2668
|
+
});
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
1967
2671
|
if (effective === 0) {
|
|
1968
2672
|
unregisterDaemon(resolved.address);
|
|
1969
2673
|
closeExistingTab();
|
|
@@ -1983,17 +2687,59 @@ export function runServiceCommand(
|
|
|
1983
2687
|
onClose(exitCode);
|
|
1984
2688
|
};
|
|
1985
2689
|
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2690
|
+
// A detached up records its process group, so `down` can stop it.
|
|
2691
|
+
let recorded: RecordedProcessGroup | null = null;
|
|
2692
|
+
const recordGroup =
|
|
2693
|
+
detachable && logPath && command !== "down"
|
|
2694
|
+
? (pid: number) => {
|
|
2695
|
+
recorded = recordProcessGroup(pid);
|
|
2696
|
+
if (recorded) updateDaemonProcessGroup(resolved.address, recorded);
|
|
2697
|
+
}
|
|
2698
|
+
: null;
|
|
2699
|
+
// A detached up whose whole group is gone when its leader exits leaves
|
|
2700
|
+
// nothing to stop: never keep its pgid (0.3.0-dev.39, a consumer's dev.38
|
|
2701
|
+
// finding: a second server that died on EADDRINUSE was recorded, and a
|
|
2702
|
+
// later down signalled the dead group while the real listener ran on).
|
|
2703
|
+
// A failed start keeps no record at all.
|
|
2704
|
+
const serviceExited = (exitCode: number): void => {
|
|
2705
|
+
const rec = recorded;
|
|
2706
|
+
if (rec && processGroupStateSync(rec)?.state === "gone") {
|
|
2707
|
+
const now = findDaemonByAddress(resolved.address);
|
|
2708
|
+
// A non-zero exit is a failed start: nothing of it runs, so no
|
|
2709
|
+
// record (its containers, if any, still run: then only the group
|
|
2710
|
+
// goes). A zero exit handed off (`make up`): its group stays, and
|
|
2711
|
+
// down says it already exited.
|
|
2712
|
+
if (now?.pgid === rec.pgid && exitCode !== 0) {
|
|
2713
|
+
if (!(now.containers?.length ?? 0)) unregisterDaemon(resolved.address);
|
|
2714
|
+
else clearDaemonProcessGroup(resolved.address);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
onServiceExit?.(exitCode);
|
|
2718
|
+
};
|
|
2719
|
+
const proc = recordGroup
|
|
2720
|
+
? spawnStreaming(
|
|
2721
|
+
cmdString,
|
|
2722
|
+
[],
|
|
2723
|
+
{ ...process.env, ...runEnv },
|
|
2724
|
+
onLine,
|
|
2725
|
+
wrappedOnClose,
|
|
2726
|
+
repoPath,
|
|
2727
|
+
detachable,
|
|
2728
|
+
logPath,
|
|
2729
|
+
serviceExited,
|
|
2730
|
+
recordGroup,
|
|
2731
|
+
)
|
|
2732
|
+
: spawnStreaming(
|
|
2733
|
+
cmdString,
|
|
2734
|
+
[],
|
|
2735
|
+
{ ...process.env, ...runEnv },
|
|
2736
|
+
onLine,
|
|
2737
|
+
wrappedOnClose,
|
|
2738
|
+
repoPath,
|
|
2739
|
+
detachable,
|
|
2740
|
+
logPath,
|
|
2741
|
+
onServiceExit,
|
|
2742
|
+
);
|
|
1997
2743
|
return { proc, detachable, interactive: false, logPath };
|
|
1998
2744
|
}
|
|
1999
2745
|
|