@monster-orange/den 0.2.4 → 0.2.6
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 +195 -0
- package/docs/distributions.md +158 -16
- package/package.json +1 -1
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +229 -87
- package/src/shared/environment-manager/health.ts +235 -35
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +242 -0
- package/src/shared/stacks/runtime.ts +73 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +86 -41
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +101 -32
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +6 -1
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +120 -40
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +7 -0
|
@@ -9,8 +9,16 @@ import { hasRuntime, repoKind } from "../repo-kind.js";
|
|
|
9
9
|
import * as https from "https";
|
|
10
10
|
import * as tls from "tls";
|
|
11
11
|
import { readEdgeConfig } from "../edge/config.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
composeHealth,
|
|
14
|
+
composeHealthTarget,
|
|
15
|
+
containerHealth,
|
|
16
|
+
type ComposeHealthTarget,
|
|
17
|
+
type ComposeVerdict,
|
|
18
|
+
type ContainerHealthTarget,
|
|
19
|
+
} from "../stacks/compose-health.js";
|
|
13
20
|
import * as fs from "fs";
|
|
21
|
+
import * as os from "os";
|
|
14
22
|
import * as path from "path";
|
|
15
23
|
import * as dns from "dns";
|
|
16
24
|
import { spawn } from "child_process";
|
|
@@ -33,12 +41,24 @@ import {
|
|
|
33
41
|
listWorkspaceServices,
|
|
34
42
|
readManifestAnyBranch,
|
|
35
43
|
} from "./workspaces.js";
|
|
36
|
-
import type {
|
|
44
|
+
import type {
|
|
45
|
+
ResolvedService,
|
|
46
|
+
ServiceCatalog,
|
|
47
|
+
ServiceHealthConfig,
|
|
48
|
+
} from "../types/index.js";
|
|
37
49
|
import type { Environment } from "../types/index.js";
|
|
38
50
|
|
|
39
51
|
import { product } from "../product.js";
|
|
40
52
|
export interface PingResult {
|
|
41
|
-
|
|
53
|
+
/**
|
|
54
|
+
* true: the check answered healthy. false: it COMPLETED and failed. null:
|
|
55
|
+
* it did not complete or could not tell (no answer within its budget, its
|
|
56
|
+
* process could not start, or it reported its state unknown). Only false
|
|
57
|
+
* is down.
|
|
58
|
+
*/
|
|
59
|
+
ok: boolean | null;
|
|
60
|
+
/** With ok null: the check gave no answer within its budget. */
|
|
61
|
+
pending?: boolean;
|
|
42
62
|
status?: number;
|
|
43
63
|
error?: string;
|
|
44
64
|
}
|
|
@@ -50,7 +70,12 @@ export interface ServiceHealthResult {
|
|
|
50
70
|
repoPath: string;
|
|
51
71
|
mode: "dev-managed" | "managed";
|
|
52
72
|
branch: string | null;
|
|
53
|
-
|
|
73
|
+
/**
|
|
74
|
+
* "pending": a check ran but gave no verdict (no answer in time, or it
|
|
75
|
+
* could not tell). Unlike "unknown" (no check configured) it is not a
|
|
76
|
+
* reason to proceed; unlike "error" it is not down.
|
|
77
|
+
*/
|
|
78
|
+
health: "ok" | "error" | "unknown" | "pending";
|
|
54
79
|
healthDetail?: string;
|
|
55
80
|
/** Where the service runs for the env checked (R13). */
|
|
56
81
|
runs?: Placement["runs"];
|
|
@@ -409,40 +434,212 @@ export async function pingShell(
|
|
|
409
434
|
cmd: string,
|
|
410
435
|
extraEnv: Record<string, string> = {},
|
|
411
436
|
cwd?: string,
|
|
437
|
+
opts: {
|
|
438
|
+
/** The answer budget (default CLI_SPAWN_TIMEOUT_MS). */
|
|
439
|
+
timeoutMs?: number;
|
|
440
|
+
} = {},
|
|
441
|
+
): Promise<PingResult> {
|
|
442
|
+
// Bounded concurrency: a health cycle starts every check at once, and a
|
|
443
|
+
// distribution's shell checks are processes. Queued checks start their
|
|
444
|
+
// budget when they start, not while they wait.
|
|
445
|
+
const release = await shellSlots.acquire();
|
|
446
|
+
try {
|
|
447
|
+
return await runShellCheck(cmd, extraEnv, cwd, opts);
|
|
448
|
+
} finally {
|
|
449
|
+
release();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** A counting semaphore. */
|
|
454
|
+
class Slots {
|
|
455
|
+
private free: number;
|
|
456
|
+
private waiting: (() => void)[] = [];
|
|
457
|
+
constructor(readonly size: number) {
|
|
458
|
+
this.free = size;
|
|
459
|
+
}
|
|
460
|
+
async acquire(): Promise<() => void> {
|
|
461
|
+
if (this.free > 0) this.free--;
|
|
462
|
+
else await new Promise<void>((r) => this.waiting.push(r));
|
|
463
|
+
let released = false;
|
|
464
|
+
return () => {
|
|
465
|
+
if (released) return;
|
|
466
|
+
released = true;
|
|
467
|
+
const next = this.waiting.shift();
|
|
468
|
+
if (next) next();
|
|
469
|
+
else this.free++;
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Shell checks running at once: max(2, cpus/2). */
|
|
475
|
+
export const SHELL_CHECK_CONCURRENCY = Math.max(
|
|
476
|
+
2,
|
|
477
|
+
Math.floor(os.cpus().length / 2),
|
|
478
|
+
);
|
|
479
|
+
const shellSlots = new Slots(SHELL_CHECK_CONCURRENCY);
|
|
480
|
+
|
|
481
|
+
function runShellCheck(
|
|
482
|
+
cmd: string,
|
|
483
|
+
extraEnv: Record<string, string>,
|
|
484
|
+
cwd: string | undefined,
|
|
485
|
+
opts: { timeoutMs?: number },
|
|
412
486
|
): Promise<PingResult> {
|
|
487
|
+
const budget = opts.timeoutMs ?? CLI_SPAWN_TIMEOUT_MS;
|
|
413
488
|
return new Promise((resolve) => {
|
|
414
489
|
let settled = false;
|
|
415
490
|
const settle = (r: PingResult): void => {
|
|
416
491
|
if (settled) return;
|
|
417
492
|
settled = true;
|
|
493
|
+
clearTimeout(timer);
|
|
418
494
|
clearTimeout(guard);
|
|
419
495
|
resolve(r);
|
|
420
496
|
};
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
497
|
+
// A check that has not answered is not a failed check: past the budget
|
|
498
|
+
// it is pending (ok null), never down. Under load a CLI-based check
|
|
499
|
+
// (`<cli> stack health`, a full tsx start) can need longer than the
|
|
500
|
+
// budget on a cold poll, and calling that "down" painted healthy stacks
|
|
501
|
+
// red until the next poll.
|
|
502
|
+
const noAnswer = (): PingResult => ({
|
|
503
|
+
ok: null,
|
|
504
|
+
pending: true,
|
|
505
|
+
error: `no answer within ${Math.round(budget / 1000)}s`,
|
|
430
506
|
});
|
|
507
|
+
let proc: ReturnType<typeof spawn>;
|
|
508
|
+
try {
|
|
509
|
+
proc = spawn(cmd, [], {
|
|
510
|
+
shell: true,
|
|
511
|
+
env: { ...process.env, ...extraEnv },
|
|
512
|
+
cwd,
|
|
513
|
+
// Output is never read: piped stdio would block a chatty script once
|
|
514
|
+
// the 64KB buffer fills, and 'close' would wait on grandchildren
|
|
515
|
+
// holding the pipes open. Nothing consumes it — don't create it.
|
|
516
|
+
stdio: "ignore",
|
|
517
|
+
});
|
|
518
|
+
} catch (err) {
|
|
519
|
+
resolve({ ok: null, error: (err as Error).message });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const timer = setTimeout(() => {
|
|
523
|
+
settle(noAnswer());
|
|
524
|
+
proc.kill("SIGTERM");
|
|
525
|
+
}, budget);
|
|
431
526
|
// 'exit', NOT 'close': close waits for stdio to drain, which a killed
|
|
432
527
|
// shell's surviving grandchildren can hold open forever — one wedged
|
|
433
528
|
// health check then hangs every /api/health poll for good. The guard
|
|
434
|
-
// timer backstops even a
|
|
435
|
-
const guard = setTimeout(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
529
|
+
// timer backstops even a kill that never lands.
|
|
530
|
+
const guard = setTimeout(() => settle(noAnswer()), budget + 1_000);
|
|
531
|
+
proc.on("exit", (code, signal) => {
|
|
532
|
+
if (code === 0) return settle({ ok: true });
|
|
533
|
+
// Killed (by the budget above, or from outside): it never answered.
|
|
534
|
+
if (code === null)
|
|
535
|
+
return settle({ ...noAnswer(), error: `killed (${signal})` });
|
|
536
|
+
settle({ ok: false, error: `exit ${code}` });
|
|
537
|
+
});
|
|
538
|
+
// The check could not be started (e.g. EAGAIN under load): no verdict.
|
|
539
|
+
proc.on("error", (err) => settle({ ok: null, error: err.message }));
|
|
443
540
|
});
|
|
444
541
|
}
|
|
445
542
|
|
|
543
|
+
// ── Timeouts: pending first, down when repeated ─────────────────────────────
|
|
544
|
+
// A check that gets no answer within its budget (an HTTP timeout, a shell
|
|
545
|
+
// check past CLI_SPAWN_TIMEOUT_MS) has not completed: one such cycle is
|
|
546
|
+
// pending, not down. A cold first probe on a loaded machine (cold docker, a
|
|
547
|
+
// tsx-started `stack health`, cold TLS) can miss the budget while the service
|
|
548
|
+
// is fine, and reporting that as down painted healthy repos red until the
|
|
549
|
+
// next poll. Two consecutive timeouts are down: a hung service still shows.
|
|
550
|
+
// The web health row counts cycles (TimeoutStreaks); a one-shot caller
|
|
551
|
+
// (`<cli> status`, checkServiceHealth) re-probes once at once instead, so
|
|
552
|
+
// both say the same thing about the same service.
|
|
553
|
+
|
|
554
|
+
/** Whether a ping got no answer within its budget. */
|
|
555
|
+
export function timedOut(r: PingResult): boolean {
|
|
556
|
+
return r.pending === true || (r.ok === false && r.error === "timeout");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A timeout's verdict: pending on its first cycle, down on a repeat. */
|
|
560
|
+
function timeoutVerdict(r: PingResult, repeated: boolean): PingResult {
|
|
561
|
+
const error = r.error ?? "timeout";
|
|
562
|
+
return repeated
|
|
563
|
+
? { ok: false, error: `${error} (twice in a row)` }
|
|
564
|
+
: { ok: null, pending: true, error };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Consecutive timed-out cycles per check, for a caller that polls. */
|
|
568
|
+
export class TimeoutStreaks {
|
|
569
|
+
private streak = new Map<string, number>();
|
|
570
|
+
/** `r` as this cycle's verdict for check `key`. */
|
|
571
|
+
judge(key: string, r: PingResult): PingResult {
|
|
572
|
+
if (!timedOut(r)) {
|
|
573
|
+
this.streak.delete(key);
|
|
574
|
+
return r;
|
|
575
|
+
}
|
|
576
|
+
const n = (this.streak.get(key) ?? 0) + 1;
|
|
577
|
+
this.streak.set(key, n);
|
|
578
|
+
return timeoutVerdict(r, n >= 2);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Probe once; on a timeout, probe again at once: down only if it times out twice. */
|
|
583
|
+
export async function pingTwiceOnTimeout(
|
|
584
|
+
probe: () => Promise<PingResult>,
|
|
585
|
+
): Promise<PingResult> {
|
|
586
|
+
const first = await probe();
|
|
587
|
+
if (!timedOut(first)) return first;
|
|
588
|
+
const second = await probe();
|
|
589
|
+
return timedOut(second) ? timeoutVerdict(second, true) : second;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Run a shell health check. Den's own compose check (the `<cli> stack
|
|
594
|
+
* health` command it generates, `compose.cmd`) is answered in-process from
|
|
595
|
+
* the shared docker listing: no CLI process per check. Any other command
|
|
596
|
+
* (a distribution's own) runs as a shell command.
|
|
597
|
+
*/
|
|
598
|
+
export async function pingShellCheck(
|
|
599
|
+
cmd: string,
|
|
600
|
+
extraEnv: Record<string, string>,
|
|
601
|
+
cwd: string | undefined,
|
|
602
|
+
compose: ComposeHealthTarget | null,
|
|
603
|
+
): Promise<PingResult> {
|
|
604
|
+
if (!compose || cmd !== compose.cmd) return pingShell(cmd, extraEnv, cwd);
|
|
605
|
+
return verdictPing(await composeHealth(compose));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* A compose or container verdict as a ping. One conversion for both, so a
|
|
610
|
+
* docker that could not be asked reads the same: `ok: null`, pending (and so
|
|
611
|
+
* down on a repeat) only when the listing ran past its budget.
|
|
612
|
+
*/
|
|
613
|
+
function verdictPing(v: ComposeVerdict): PingResult {
|
|
614
|
+
if (v.ok === true) return { ok: true };
|
|
615
|
+
if (v.ok === false) return { ok: false, error: v.detail };
|
|
616
|
+
return { ok: null, error: v.detail, ...(v.pending ? { pending: true } : {}) };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Run a `type: container` check: answered in-process from the shared docker
|
|
621
|
+
* listing (the one Den's compose checks use), never a docker call of its own.
|
|
622
|
+
*/
|
|
623
|
+
export async function pingContainerCheck(
|
|
624
|
+
target: ContainerHealthTarget,
|
|
625
|
+
): Promise<PingResult> {
|
|
626
|
+
return verdictPing(await containerHealth(target));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The container a `type: container` health config names. */
|
|
630
|
+
export function containerTarget(
|
|
631
|
+
h: Extract<ServiceHealthConfig, { type: "container" }>,
|
|
632
|
+
): ContainerHealthTarget {
|
|
633
|
+
return h.container_name !== undefined
|
|
634
|
+
? { container_name: h.container_name }
|
|
635
|
+
: { project: h.project, service: h.service };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** A ping result as a ServiceHealthResult health state. */
|
|
639
|
+
export function healthState(r: PingResult): ServiceHealthResult["health"] {
|
|
640
|
+
return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
|
|
641
|
+
}
|
|
642
|
+
|
|
446
643
|
// ── Per-service health check ──────────────────────────────────────────────────
|
|
447
644
|
|
|
448
645
|
/**
|
|
@@ -480,9 +677,9 @@ export async function checkServiceHealth(
|
|
|
480
677
|
};
|
|
481
678
|
}
|
|
482
679
|
|
|
483
|
-
const
|
|
680
|
+
const compose =
|
|
484
681
|
env === "local"
|
|
485
|
-
?
|
|
682
|
+
? composeHealthTarget(
|
|
486
683
|
manifest,
|
|
487
684
|
resolved.subsystem,
|
|
488
685
|
resolved.repoName,
|
|
@@ -493,7 +690,7 @@ export async function checkServiceHealth(
|
|
|
493
690
|
manifest,
|
|
494
691
|
resolved.subsystem,
|
|
495
692
|
env,
|
|
496
|
-
|
|
693
|
+
compose?.cmd ?? null,
|
|
497
694
|
);
|
|
498
695
|
const placed: Pick<ServiceHealthResult, "runs" | "target"> = {
|
|
499
696
|
runs: plan.placement.runs,
|
|
@@ -544,21 +741,24 @@ export async function checkServiceHealth(
|
|
|
544
741
|
};
|
|
545
742
|
}
|
|
546
743
|
|
|
547
|
-
const result =
|
|
744
|
+
const result = await pingTwiceOnTimeout(() =>
|
|
548
745
|
healthCfg.type === "shell"
|
|
549
|
-
?
|
|
550
|
-
:
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
746
|
+
? pingShellCheck(healthCfg.cmd, {}, resolved.repoPath, compose)
|
|
747
|
+
: healthCfg.type === "container"
|
|
748
|
+
? pingContainerCheck(containerTarget(healthCfg))
|
|
749
|
+
: pingHttp(
|
|
750
|
+
healthCfg.url,
|
|
751
|
+
env === "local" && plan.placement.runs !== "remote"
|
|
752
|
+
? HEALTH_CHECK_TIMEOUT_MS
|
|
753
|
+
: HOSTED_HEALTH_CHECK_TIMEOUT_MS,
|
|
754
|
+
plan.expect,
|
|
755
|
+
),
|
|
756
|
+
);
|
|
557
757
|
|
|
558
758
|
return {
|
|
559
759
|
...base,
|
|
560
760
|
...placed,
|
|
561
|
-
health: result
|
|
761
|
+
health: healthState(result),
|
|
562
762
|
healthDetail:
|
|
563
763
|
result.error ??
|
|
564
764
|
(!result.ok && result.status !== undefined
|
|
@@ -52,7 +52,7 @@ export type IsProduction = (env: string) => boolean;
|
|
|
52
52
|
const LOCAL = "local";
|
|
53
53
|
|
|
54
54
|
function isHttp(h: ServiceHealthConfig | undefined): h is { url: string } {
|
|
55
|
-
return !!h &&
|
|
55
|
+
return !!h && "url" in h && typeof h.url === "string";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function hasCommands(
|
|
@@ -32,7 +32,54 @@ const commandDef = z.union([
|
|
|
32
32
|
// { url: "http://..." } — implicit HTTP
|
|
33
33
|
// { type: "http", url: "http://..." } — explicit HTTP
|
|
34
34
|
// { type: "shell", cmd: "pg_isready ..." } — shell
|
|
35
|
+
// { type: "container", project, service } — a compose container
|
|
36
|
+
// { type: "container", container_name } — a container by name
|
|
37
|
+
const CONTAINER_HEALTH_KEYS = new Set([
|
|
38
|
+
"type",
|
|
39
|
+
"project",
|
|
40
|
+
"service",
|
|
41
|
+
"container_name",
|
|
42
|
+
"label",
|
|
43
|
+
]);
|
|
44
|
+
export const CONTAINER_HEALTH_SHAPE =
|
|
45
|
+
'container checks need "project" and "service" (compose labels), or "container_name"';
|
|
46
|
+
const containerHealth = z
|
|
47
|
+
.object({
|
|
48
|
+
type: z.literal("container"),
|
|
49
|
+
project: z.string().min(1).optional(),
|
|
50
|
+
service: z.string().min(1).optional(),
|
|
51
|
+
container_name: z.string().min(1).optional(),
|
|
52
|
+
label: z.string().optional(),
|
|
53
|
+
})
|
|
54
|
+
.passthrough()
|
|
55
|
+
.superRefine((h, ctx) => {
|
|
56
|
+
const unknown = Object.keys(h).filter((k) => !CONTAINER_HEALTH_KEYS.has(k));
|
|
57
|
+
if (unknown.length)
|
|
58
|
+
ctx.addIssue({
|
|
59
|
+
code: z.ZodIssueCode.custom,
|
|
60
|
+
message: `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} in a container check: ${CONTAINER_HEALTH_SHAPE}`,
|
|
61
|
+
});
|
|
62
|
+
const byLabels = h.project !== undefined || h.service !== undefined;
|
|
63
|
+
const byName = h.container_name !== undefined;
|
|
64
|
+
if (byLabels && byName)
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: z.ZodIssueCode.custom,
|
|
67
|
+
message: `a container check matches by "project"/"service" or by "container_name", not both`,
|
|
68
|
+
});
|
|
69
|
+
else if (byLabels && (h.project === undefined || h.service === undefined))
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: z.ZodIssueCode.custom,
|
|
72
|
+
message: `a container check by compose labels needs both "project" and "service"`,
|
|
73
|
+
});
|
|
74
|
+
else if (!byLabels && !byName)
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: z.ZodIssueCode.custom,
|
|
77
|
+
message: CONTAINER_HEALTH_SHAPE,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
35
81
|
const healthConfig = z.union([
|
|
82
|
+
containerHealth,
|
|
36
83
|
// Shell health check — must have cmd
|
|
37
84
|
z.object({
|
|
38
85
|
type: z.literal("shell"),
|
|
@@ -47,7 +47,7 @@ export function openUrlFor(
|
|
|
47
47
|
const declared = httpUrl(svc.urls?.[env]);
|
|
48
48
|
if (declared) return declared;
|
|
49
49
|
const health = svc.health?.[env];
|
|
50
|
-
if (!health || ("
|
|
50
|
+
if (!health || !("url" in health)) return null;
|
|
51
51
|
return httpOrigin((health as { url?: string }).url);
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -57,7 +57,7 @@ function suggestionFor(issue: z.ZodIssue): string | undefined {
|
|
|
57
57
|
|
|
58
58
|
// health with missing url/cmd
|
|
59
59
|
if (pathStr.includes("health")) {
|
|
60
|
-
return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"';
|
|
60
|
+
return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"; container checks need "type: container" and "project" + "service", or "container_name"';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
return undefined;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts in output: "1 repo", "3 repos", never "1 repos" or "repo(s)".
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** `word` in the form `n` takes: "repo" for 1, "repos" (or `many`) otherwise. */
|
|
6
|
+
export function noun(n: number, word: string, many = `${word}s`): string {
|
|
7
|
+
return n === 1 ? word : many;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** `n` and its noun: plural(1, "repo") → "1 repo", plural(2, "repo") → "2 repos". */
|
|
11
|
+
export function plural(n: number, word: string, many?: string): string {
|
|
12
|
+
return `${n} ${noun(n, word, many)}`;
|
|
13
|
+
}
|
|
@@ -24,11 +24,8 @@ function healthUrlForConfig(healthConfig?: ServiceHealthConfig): string | null {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return healthConfig.url;
|
|
27
|
+
// Shell and container checks have no URL.
|
|
28
|
+
return "url" in healthConfig ? healthConfig.url : null;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
function normalizeServiceBaseUrl(
|