@tdk-landscape/tdk-cli-core 1.3.120 → 1.3.122
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 +1 -0
- package/README.md +21 -14
- package/cli/dist/cli.js +1 -1
- package/cli/dist/commands/config.js +6 -2
- package/cli/dist/commands/doctor.js +64 -7
- package/cli/dist/commands/help.js +17 -1
- package/cli/dist/commands/logs.js +34 -7
- package/cli/dist/commands/networks.js +1 -1
- package/cli/dist/commands/resources.js +1 -1
- package/cli/dist/commands/stack.js +94 -22
- package/cli/dist/commands/status.js +35 -2
- package/cli/dist/commands/ui.js +105 -9
- package/cli/dist/commands/up.js +105 -28
- package/cli/dist/components/ServiceIssues.js +12 -0
- package/cli/dist/components/TabBar.js +3 -2
- package/cli/dist/components/index.js +1 -0
- package/cli/dist/generator/__tests__/golden-helpers.js +42 -0
- package/cli/dist/generator/template-engine.js +5 -3
- package/cli/dist/utils/atomic-write.js +35 -0
- package/cli/dist/utils/bun-floor.js +8 -0
- package/cli/dist/utils/cold-preflight.js +6 -4
- package/cli/dist/utils/doctor-runtime.js +6 -6
- package/cli/dist/utils/doctor-wiring.js +62 -1
- package/cli/dist/utils/errors.js +20 -2
- package/cli/dist/utils/host-port-plan.js +3 -0
- package/cli/dist/utils/machine-output.js +8 -1
- package/cli/dist/utils/port-conflict-fix.js +24 -0
- package/cli/dist/utils/port-fallback-notice.js +18 -0
- package/cli/dist/utils/port-listener-probe.js +19 -0
- package/cli/dist/utils/secret-redaction.js +55 -0
- package/cli/dist/utils/service-config-checks.js +188 -0
- package/cli/dist/utils/service-runtime-state.js +67 -0
- package/cli/dist/utils/services.js +2 -15
- package/cli/dist/utils/single-flight.js +20 -0
- package/cli/dist/utils/stack-status.js +15 -0
- package/cli/dist/utils/startup-report.js +73 -0
- package/cli/dist/utils/suggestions.js +58 -0
- package/cli/dist/utils/tilt-events.js +195 -0
- package/cli/dist/utils/tilt-resource-state.js +12 -0
- package/cli/dist/utils/tilt-startup.js +7 -0
- package/cli/dist/utils/tilt-unreachable-message.js +8 -0
- package/cli/dist/utils/ui-service-state.js +35 -0
- package/cli/dist/utils/up-readiness.js +2 -0
- package/cli/package.json +1 -1
- package/cli/templates/.tiltignore.hbs +1 -2
- package/cli/templates/TILT_RESOURCE_DEFAULTS.star.hbs +1 -1
- package/cli/templates/TILT_TECH_STACK.star.hbs +5 -5
- package/cli/templates/Tiltfile.hbs +3 -3
- package/cli/templates/spec.master.hbs +1 -1
- package/engine/topologies/platform/docker/compose/compose.star +1 -86
- package/engine/topologies/platform/docker/compose/traefik_standalone.star +4 -4
- package/engine/topologies/platform/docker/networking/traefik.star +40 -7
- package/engine/topologies/platform/docker/networking/traefik_helpers.star +1 -1
- package/engine/topologies/tilt/resources/infra-loader.star +3 -2
- package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +20 -0
- package/package.json +1 -1
|
@@ -2,11 +2,13 @@ import { execFileSync, execSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:net";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { STANDARD_PORTS } from "./constants.js";
|
|
5
6
|
import { formatCount } from "./formatting.js";
|
|
6
7
|
import { findProjectRoot } from "./paths.js";
|
|
7
8
|
import { isApiServiceType } from "./resource-kind.js";
|
|
8
9
|
import { getProjectName } from "./service-urls.js";
|
|
9
10
|
import { discoverResources, discoverResourcesFromRoot } from "./services.js";
|
|
11
|
+
import { isTiltResourcePending } from "./tilt-resource-state.js";
|
|
10
12
|
import { findOnPath } from "./which.js";
|
|
11
13
|
const EXEC_TIMEOUT_MS = 10_000;
|
|
12
14
|
const REGISTRY_PROBE_TIMEOUT_MS = 3_000;
|
|
@@ -119,11 +121,7 @@ export function parseTiltResourceFailures(jsonText, deferredNames = new Set()) {
|
|
|
119
121
|
okCount += 1;
|
|
120
122
|
continue;
|
|
121
123
|
}
|
|
122
|
-
if (name !== "(Tiltfile)" &&
|
|
123
|
-
(updateStatus === "pending" ||
|
|
124
|
-
runtimeStatus === "pending" ||
|
|
125
|
-
updateStatus === "none" ||
|
|
126
|
-
runtimeStatus === "none")) {
|
|
124
|
+
if (name !== "(Tiltfile)" && isTiltResourcePending(updateStatus, runtimeStatus)) {
|
|
127
125
|
if (deferredNames.has(name)) {
|
|
128
126
|
deferredCount += 1;
|
|
129
127
|
}
|
|
@@ -593,7 +591,9 @@ export function checkTiltResourceHealth(exec = execSync) {
|
|
|
593
591
|
const hasRegistryFailure = ordered.some((failure) => isRegistryRelatedBuildError(failure.error) ||
|
|
594
592
|
isRegistryRelatedBuildError(summarizeTiltBuildError(failure.error)));
|
|
595
593
|
const hasKafkaRuntimeFailure = described.some(({ why }) => /Kafka broker unreachable|kafka:9092/i.test(why));
|
|
596
|
-
let fix = "Open
|
|
594
|
+
let fix = "Open the Tilt UI URL printed by tdk up (default port " +
|
|
595
|
+
STANDARD_PORTS.tiltUi +
|
|
596
|
+
" when TILT_PORT is unset), inspect the red resources, fix the listed errors, then `tilt trigger <resource>` or re-run `tdk up`.";
|
|
597
597
|
if (hasPortConflict) {
|
|
598
598
|
fix =
|
|
599
599
|
"Free the conflicting host port (usually 80/443): `docker ps --filter publish=80` then `docker stop <other-traefik>`. Re-trigger Traefik in the Tilt UI or run `tdk up` again.";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync, execSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
-
import { join, relative } from "node:path";
|
|
3
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
4
4
|
import { BACKEND_LANGUAGES } from "../backend-languages/registry.js";
|
|
5
5
|
import { execAsync } from "./exec-async.js";
|
|
6
6
|
import { formatCount } from "./formatting.js";
|
|
@@ -328,6 +328,67 @@ function listSourceFiles(dir) {
|
|
|
328
328
|
}
|
|
329
329
|
const LOCALHOST_PORT = /(?:localhost|127\.0\.0\.1):(\d{2,5})\b/g;
|
|
330
330
|
const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|<!--|#)/;
|
|
331
|
+
/**
|
|
332
|
+
* Two API backends in one stack used to share the stack-scoped Traefik routers (`/api/<stack>-management`), so the same rule
|
|
333
|
+
* matched both and Traefik chose between them arbitrarily. The engine now counts the routable backends of a stack inside one
|
|
334
|
+
* service directory (the same grouping as here: resources in one directory that name the same stack). When there are two or more,
|
|
335
|
+
* it drops the stack routers for all of them. A backend that sets its own `traefik.host` / `traefik.pathPrefix` keeps a router
|
|
336
|
+
* for just that route; the others are left with `/api/<name>` only. This check warns about the backends that lose the stack path
|
|
337
|
+
* and about two backends that set the same explicit route.
|
|
338
|
+
*/
|
|
339
|
+
export function checkSharedStackRoutes(projectRoot = findProjectRoot() ?? process.cwd()) {
|
|
340
|
+
const groups = new Map();
|
|
341
|
+
for (const resource of discoverResourcesFromRoot(projectRoot)) {
|
|
342
|
+
const config = resource.config;
|
|
343
|
+
const routable = isApiServiceType(config?.appType) ||
|
|
344
|
+
(config?.appType === "bring-your-own" && config.exposeViaProxy !== false);
|
|
345
|
+
if (!routable)
|
|
346
|
+
continue;
|
|
347
|
+
const directory = dirname(resource.path);
|
|
348
|
+
const stack = config?.stack ?? basename(directory);
|
|
349
|
+
const key = `${directory}\0${stack}`;
|
|
350
|
+
const group = groups.get(key) ?? { stack, resources: [] };
|
|
351
|
+
group.resources.push(resource);
|
|
352
|
+
groups.set(key, group);
|
|
353
|
+
}
|
|
354
|
+
const problems = [];
|
|
355
|
+
for (const { stack, resources } of groups.values()) {
|
|
356
|
+
if (resources.length < 2)
|
|
357
|
+
continue;
|
|
358
|
+
const explicit = (resource) => Boolean(resource.config?.traefik?.host || resource.config?.traefik?.pathPrefix);
|
|
359
|
+
const lost = resources.filter((resource) => !explicit(resource)).map((r) => r.name);
|
|
360
|
+
if (lost.length > 0) {
|
|
361
|
+
problems.push(`stack "${stack}": ${lost.sort().join(", ")} lose the shared /api/${stack}-management route because ${resources.length} backends share the stack; they are routed at /api/<name> only`);
|
|
362
|
+
}
|
|
363
|
+
for (const field of ["host", "pathPrefix"]) {
|
|
364
|
+
const byValue = new Map();
|
|
365
|
+
for (const resource of resources) {
|
|
366
|
+
const value = resource.config?.traefik?.[field];
|
|
367
|
+
if (value)
|
|
368
|
+
byValue.set(value, [...(byValue.get(value) ?? []), resource.name]);
|
|
369
|
+
}
|
|
370
|
+
for (const [value, names] of byValue) {
|
|
371
|
+
if (names.length > 1) {
|
|
372
|
+
problems.push(`stack "${stack}": ${names.sort().join(" and ")} both set traefik.${field} "${value}", so Traefik cannot tell them apart`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (problems.length === 0) {
|
|
378
|
+
return {
|
|
379
|
+
name: "Stack API routes",
|
|
380
|
+
didPass: true,
|
|
381
|
+
message: "No two backends in one service directory compete for one stack route",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
name: "Stack API routes",
|
|
386
|
+
didPass: false,
|
|
387
|
+
isWarning: true,
|
|
388
|
+
message: problems.join("\n "),
|
|
389
|
+
fix: 'Call each backend at /api/<name>, move one backend to its own stack, or give each its own "traefik.pathPrefix" in service.json',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
331
392
|
export function checkFrontendBackendUrls(projectRoot = findProjectRoot() ?? process.cwd()) {
|
|
332
393
|
const resources = discoverResourcesFromRoot(projectRoot);
|
|
333
394
|
const byPort = new Map([...backendPorts(resources)].map(([name, port]) => [port, name]));
|
package/cli/dist/utils/errors.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import { QUICKSTART_DOCS_URL } from "./constants.js";
|
|
3
3
|
import { getContainerRuntimeStatus } from "./docker.js";
|
|
4
4
|
import { findProjectRoot } from "./paths.js";
|
|
5
|
+
import { suggestClosest } from "./suggestions.js";
|
|
5
6
|
import { isTiltAvailable } from "./tilt.js";
|
|
6
7
|
export function getErrorMessage(err) {
|
|
7
8
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -30,6 +31,16 @@ export class TdkError extends Error {
|
|
|
30
31
|
process.exit(this.exitCode);
|
|
31
32
|
}
|
|
32
33
|
}
|
|
34
|
+
function closestNameSuggestions(names, candidates, includeInput = false) {
|
|
35
|
+
const suggestions = new Set();
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const closest = suggestClosest(name, candidates);
|
|
38
|
+
if (closest) {
|
|
39
|
+
suggestions.add(includeInput ? `Did you mean "${closest}" for "${name}"?` : `Did you mean "${closest}"?`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...suggestions];
|
|
43
|
+
}
|
|
33
44
|
export const errorFactories = {
|
|
34
45
|
tiltNotInstalled: () => new TdkError("Tilt CLI is not installed", [
|
|
35
46
|
"Install Tilt: `brew install tilt` (macOS)",
|
|
@@ -47,14 +58,21 @@ export const errorFactories = {
|
|
|
47
58
|
"Quit and reopen Docker Desktop, or run `colima restart`",
|
|
48
59
|
"Then verify with: `docker ps`",
|
|
49
60
|
]),
|
|
50
|
-
stackNotFound: (name) => new TdkError(`Stack "${name}" not found`, [
|
|
61
|
+
stackNotFound: (name, stackNames = []) => new TdkError(`Stack "${name}" not found`, [
|
|
62
|
+
...closestNameSuggestions([name], stackNames),
|
|
51
63
|
"Run `tdk stacks` to see available stacks",
|
|
52
64
|
"Run `tdk stack` to assign resources to a stack",
|
|
53
65
|
]),
|
|
54
|
-
resourceNotFound: (name) => new TdkError(`Resource "${name}" not found`, [
|
|
66
|
+
resourceNotFound: (name, resourceNames = []) => new TdkError(`Resource "${name}" not found`, [
|
|
67
|
+
...closestNameSuggestions([name], resourceNames),
|
|
55
68
|
"Run `tdk resources` to list all resources",
|
|
56
69
|
"Check the resource name spelling",
|
|
57
70
|
]),
|
|
71
|
+
unknownServices: (names, validNames) => {
|
|
72
|
+
// Service selectors are Tilt resource names, so share the resource suggestion path.
|
|
73
|
+
const suggestions = closestNameSuggestions(names, validNames, names.length > 1);
|
|
74
|
+
return new TdkError(`Unknown service ${names.join(", ")}. Valid names: ${validNames.join(", ")}`, suggestions, 2);
|
|
75
|
+
},
|
|
58
76
|
directoryExists: (path) => new TdkError(`Directory already exists: ${path}`, [
|
|
59
77
|
"Use `--path` to specify a different location",
|
|
60
78
|
"Remove the existing directory if no longer needed",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:net";
|
|
2
|
+
import { hasLocalListener } from "./port-listener-probe.js";
|
|
2
3
|
export const DEFAULT_HOST_PORT_RANGES = {
|
|
3
4
|
ingressHttp: { start: 8080, end: 8180 },
|
|
4
5
|
ingressHttps: { start: 8443, end: 8543 },
|
|
@@ -11,6 +12,8 @@ const ENV_KEYS = {
|
|
|
11
12
|
};
|
|
12
13
|
const REQUESTED_PORTS = { ingressHttp: 80, ingressHttps: 443, postgres: 5432 };
|
|
13
14
|
export async function isHostPortAvailable(port) {
|
|
15
|
+
if (await hasLocalListener(port))
|
|
16
|
+
return false;
|
|
14
17
|
return new Promise((resolve) => {
|
|
15
18
|
const server = createServer();
|
|
16
19
|
server.once("error", () => resolve(false));
|
|
@@ -4,7 +4,14 @@ export function createMachineEnvelope(data, errors = []) {
|
|
|
4
4
|
}
|
|
5
5
|
export function toMachineError(error) {
|
|
6
6
|
if (error instanceof TdkError) {
|
|
7
|
-
return {
|
|
7
|
+
return {
|
|
8
|
+
error: {
|
|
9
|
+
code: "COMMAND_FAILED",
|
|
10
|
+
message: error.message,
|
|
11
|
+
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
|
|
12
|
+
},
|
|
13
|
+
exitCode: error.exitCode,
|
|
14
|
+
};
|
|
8
15
|
}
|
|
9
16
|
return {
|
|
10
17
|
error: {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The env var that moves each host port TDK publishes. Services publish no host ports, so only these conflict. */
|
|
2
|
+
const PORT_ENV_VARS = {
|
|
3
|
+
"80": "TDK_HTTP_PORT",
|
|
4
|
+
"443": "TDK_HTTPS_PORT",
|
|
5
|
+
"5432": "TDK_POSTGRES_PORT",
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The next step for a host port another process already holds. The occupant is not named: finding it needs the OS, so
|
|
9
|
+
* the command that finds it is given instead.
|
|
10
|
+
*/
|
|
11
|
+
export function portConflictFix(message) {
|
|
12
|
+
// The raw Docker error can reach the report when the readiness check fails before the summary runs.
|
|
13
|
+
const match = message.match(/^host port (\d+) is already allocated$/) ??
|
|
14
|
+
message.match(/Bind for [^ ]+:(\d+) failed: port is already allocated/i);
|
|
15
|
+
if (!match)
|
|
16
|
+
return null;
|
|
17
|
+
const port = match[1];
|
|
18
|
+
const find = `find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN`;
|
|
19
|
+
const envVar = PORT_ENV_VARS[port];
|
|
20
|
+
if (!envVar)
|
|
21
|
+
return `Fix: stop the process using port ${port} (${find})`;
|
|
22
|
+
return `Fix: stop the process using port ${port} (${find}), or set ${envVar} to a free port and run tdk up`;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=port-conflict-fix.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const PORT_KEYS = [
|
|
2
|
+
{ key: "ingressHttp", label: "HTTP", env: "TDK_HTTP_PORT" },
|
|
3
|
+
{ key: "ingressHttps", label: "HTTPS", env: "TDK_HTTPS_PORT" },
|
|
4
|
+
{ key: "postgres", label: "Postgres", env: "TDK_POSTGRES_PORT" },
|
|
5
|
+
];
|
|
6
|
+
/** One line per port TDK moved off the one it wanted. Ports the user set explicitly are never listed. */
|
|
7
|
+
export function formatPortFallbackNotice(plan) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
for (const { key, label, env } of PORT_KEYS) {
|
|
10
|
+
const requested = plan.requested[key];
|
|
11
|
+
const chosen = plan[key];
|
|
12
|
+
if (plan.explicit[key] || chosen === requested)
|
|
13
|
+
continue;
|
|
14
|
+
lines.push(`${label}: wanted ${requested}, using ${chosen} (${plan.reason[key]}). To force ${requested}: ${env}=${requested} tdk up`);
|
|
15
|
+
}
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=port-fallback-notice.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
2
|
+
/**
|
|
3
|
+
* True when something already accepts connections on 127.0.0.1:port. Binding a socket to find out is not enough:
|
|
4
|
+
* on macOS a bind on 127.0.0.1 succeeds even while another process listens on the same port, so a busy port such as
|
|
5
|
+
* 5432 looked free and `tdk doctor` passed before Docker failed to publish it.
|
|
6
|
+
*/
|
|
7
|
+
export function hasLocalListener(port, timeoutMs = 500) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const socket = connect({ port, host: "127.0.0.1" });
|
|
10
|
+
const finish = (result) => {
|
|
11
|
+
socket.destroy();
|
|
12
|
+
resolve(result);
|
|
13
|
+
};
|
|
14
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
15
|
+
socket.once("connect", () => finish(true));
|
|
16
|
+
socket.once("error", () => finish(false));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=port-listener-probe.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseEnv } from "./env-validator.js";
|
|
4
|
+
export const REDACTED = "[REDACTED]";
|
|
5
|
+
export class EnvUnreadableError extends Error {
|
|
6
|
+
path;
|
|
7
|
+
constructor(path, reason) {
|
|
8
|
+
super(`Could not read ${path}: ${reason}`);
|
|
9
|
+
this.path = path;
|
|
10
|
+
this.name = "EnvUnreadableError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Shorter values are left alone: masking a two-letter value would hit ordinary words in every log line. The cost is
|
|
15
|
+
* that a short secret in .env is not masked, so keep real secrets at least this long.
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_REDACTED_VALUE_LENGTH = 8;
|
|
18
|
+
/**
|
|
19
|
+
* Values of the project's own .env file, longest first so a value that contains another is masked whole. Throws when the
|
|
20
|
+
* file exists but cannot be read: continuing without the values would print secrets unmasked.
|
|
21
|
+
*/
|
|
22
|
+
export function envSecretValues(projectRoot) {
|
|
23
|
+
const envPath = join(projectRoot, ".env");
|
|
24
|
+
if (!existsSync(envPath))
|
|
25
|
+
return [];
|
|
26
|
+
let content;
|
|
27
|
+
try {
|
|
28
|
+
content = readFileSync(envPath, "utf-8");
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new EnvUnreadableError(envPath, error instanceof Error ? error.message : String(error));
|
|
32
|
+
}
|
|
33
|
+
const values = [...parseEnv(content).values()].filter((value) => value.length >= MIN_REDACTED_VALUE_LENGTH);
|
|
34
|
+
return [...new Set(values)].sort((a, b) => b.length - a.length);
|
|
35
|
+
}
|
|
36
|
+
export function redactSecrets(text, secrets) {
|
|
37
|
+
let result = text;
|
|
38
|
+
for (const secret of secrets)
|
|
39
|
+
result = result.split(secret).join(REDACTED);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
/** Redacts every string inside a JSON-shaped value, so the output stays valid JSON. */
|
|
43
|
+
export function redactValue(value, secrets) {
|
|
44
|
+
if (secrets.length === 0)
|
|
45
|
+
return value;
|
|
46
|
+
if (typeof value === "string")
|
|
47
|
+
return redactSecrets(value, secrets);
|
|
48
|
+
if (Array.isArray(value))
|
|
49
|
+
return value.map((item) => redactValue(item, secrets));
|
|
50
|
+
if (value !== null && typeof value === "object") {
|
|
51
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactValue(item, secrets)]));
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=secret-redaction.js.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { checkDuplicateResourceNames } from "./doctor-wiring.js";
|
|
4
|
+
import { formatCount } from "./formatting.js";
|
|
5
|
+
import { findProjectRoot } from "./paths.js";
|
|
6
|
+
import { SERVICE_MANIFEST_SCHEMA_VERSION } from "./service-manifest.js";
|
|
7
|
+
import { discoverResourcesFromRoot } from "./services.js";
|
|
8
|
+
function filePath(projectRoot, resource) {
|
|
9
|
+
return relative(projectRoot, resource.configPath) || resource.configPath;
|
|
10
|
+
}
|
|
11
|
+
/** The dependsOn names a service declares: none when absent, null when it is not an array of strings. */
|
|
12
|
+
function declaredDependsOn(resource) {
|
|
13
|
+
const value = resource.config?.dependsOn;
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return [];
|
|
16
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
|
|
17
|
+
return value;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/** Each cycle once, as service names starting from the alphabetically first, ending where it started. */
|
|
21
|
+
function findDependencyCycles(resources) {
|
|
22
|
+
const known = new Set(resources.map((r) => r.name));
|
|
23
|
+
const edges = new Map(resources.map((r) => [r.name, (declaredDependsOn(r) ?? []).filter((dep) => known.has(dep))]));
|
|
24
|
+
const cycles = new Map();
|
|
25
|
+
const state = new Map();
|
|
26
|
+
const stack = [];
|
|
27
|
+
const visit = (name) => {
|
|
28
|
+
state.set(name, "visiting");
|
|
29
|
+
stack.push(name);
|
|
30
|
+
for (const dep of edges.get(name) ?? []) {
|
|
31
|
+
if (state.get(dep) === "visiting") {
|
|
32
|
+
const loop = stack.slice(stack.indexOf(dep));
|
|
33
|
+
const start = loop.indexOf([...loop].sort()[0] ?? dep);
|
|
34
|
+
const ordered = [...loop.slice(start), ...loop.slice(0, start)];
|
|
35
|
+
cycles.set(ordered.join(">"), [...ordered, ordered[0] ?? dep]);
|
|
36
|
+
}
|
|
37
|
+
else if (!state.has(dep)) {
|
|
38
|
+
visit(dep);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
stack.pop();
|
|
42
|
+
state.set(name, "done");
|
|
43
|
+
};
|
|
44
|
+
for (const name of [...edges.keys()].sort()) {
|
|
45
|
+
if (!state.has(name))
|
|
46
|
+
visit(name);
|
|
47
|
+
}
|
|
48
|
+
return [...cycles.values()];
|
|
49
|
+
}
|
|
50
|
+
/** A dependsOn that is not a list of service names cannot be followed, so say so instead of failing later. */
|
|
51
|
+
export function checkDependsOnShape(projectRoot = findProjectRoot() ?? process.cwd()) {
|
|
52
|
+
const problems = discoverResourcesFromRoot(projectRoot).flatMap((resource) => declaredDependsOn(resource) === null
|
|
53
|
+
? [
|
|
54
|
+
`${filePath(projectRoot, resource)} dependsOn ${JSON.stringify(resource.config?.dependsOn)}: expected an array of service names`,
|
|
55
|
+
]
|
|
56
|
+
: []);
|
|
57
|
+
if (problems.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
name: "dependsOn",
|
|
60
|
+
didPass: true,
|
|
61
|
+
message: "Every dependsOn is a list of service names",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
name: "dependsOn",
|
|
66
|
+
didPass: false,
|
|
67
|
+
message: `${formatCount(problems.length, "invalid dependsOn", "invalid dependsOn")}:\n ${problems.join("\n ")}`,
|
|
68
|
+
fix: 'Write "dependsOn" as an array of service names, for example ["postgres", "api"]',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Services that wait on each other can never both start. A dependsOn name that is not a service is left to the unknown-name check. */
|
|
72
|
+
export function checkCircularDependencies(projectRoot = findProjectRoot() ?? process.cwd()) {
|
|
73
|
+
const resources = discoverResourcesFromRoot(projectRoot);
|
|
74
|
+
const byName = new Map(resources.map((r) => [r.name, r]));
|
|
75
|
+
const cycles = findDependencyCycles(resources);
|
|
76
|
+
if (cycles.length === 0) {
|
|
77
|
+
return {
|
|
78
|
+
name: "Circular dependencies",
|
|
79
|
+
didPass: true,
|
|
80
|
+
message: "No service depends on itself through dependsOn",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const lines = cycles.map((cycle) => {
|
|
84
|
+
const first = byName.get(cycle[0] ?? "");
|
|
85
|
+
const next = cycle[1] ?? "";
|
|
86
|
+
return `${first ? filePath(projectRoot, first) : cycle[0]} dependsOn "${next}": circular dependency ${cycle.join(" -> ")}`;
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
name: "Circular dependencies",
|
|
90
|
+
didPass: false,
|
|
91
|
+
message: `${cycles.length} ${cycles.length === 1 ? "circular dependency" : "circular dependencies"}:\n ${lines.join("\n ")}`,
|
|
92
|
+
fix: 'Remove one "dependsOn" entry in each cycle so the services no longer wait on each other, or move what they share into a third service',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** A port outside 1-65535 can never be bound, and `tdk up` would otherwise find out only when Docker fails. */
|
|
96
|
+
export function checkServicePorts(projectRoot = findProjectRoot() ?? process.cwd()) {
|
|
97
|
+
const problems = discoverResourcesFromRoot(projectRoot).flatMap((resource) => {
|
|
98
|
+
const port = resource.config?.port;
|
|
99
|
+
if (port === undefined)
|
|
100
|
+
return [];
|
|
101
|
+
if (typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535)
|
|
102
|
+
return [];
|
|
103
|
+
return [
|
|
104
|
+
`${filePath(projectRoot, resource)} port ${JSON.stringify(port)}: not an integer from 1 to 65535`,
|
|
105
|
+
];
|
|
106
|
+
});
|
|
107
|
+
if (problems.length === 0) {
|
|
108
|
+
return { name: "Service ports", didPass: true, message: "Every declared port is valid" };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
name: "Service ports",
|
|
112
|
+
didPass: false,
|
|
113
|
+
message: `${formatCount(problems.length, "invalid port")}:\n ${problems.join("\n ")}`,
|
|
114
|
+
fix: 'Set "port" to the port the service listens on, an integer from 1 to 65535',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A service.json with no schemaVersion is how every project began, so this is a warning: it keeps working, and only
|
|
119
|
+
* `tdk config migrate` writes the field. A version this tdk does not know is reported the same way, with a different fix.
|
|
120
|
+
*/
|
|
121
|
+
export function checkSchemaVersions(projectRoot = findProjectRoot() ?? process.cwd(), strict = false) {
|
|
122
|
+
const missing = [];
|
|
123
|
+
const unsupported = [];
|
|
124
|
+
for (const resource of discoverResourcesFromRoot(projectRoot)) {
|
|
125
|
+
const version = resource.config?.schemaVersion;
|
|
126
|
+
if (version === SERVICE_MANIFEST_SCHEMA_VERSION)
|
|
127
|
+
continue;
|
|
128
|
+
const file = filePath(projectRoot, resource);
|
|
129
|
+
if (version === undefined)
|
|
130
|
+
missing.push(`${file} schemaVersion: missing`);
|
|
131
|
+
else
|
|
132
|
+
unsupported.push(`${file} schemaVersion ${JSON.stringify(version)}: this tdk supports ${SERVICE_MANIFEST_SCHEMA_VERSION}`);
|
|
133
|
+
}
|
|
134
|
+
if (missing.length === 0 && unsupported.length === 0) {
|
|
135
|
+
return {
|
|
136
|
+
name: "schemaVersion",
|
|
137
|
+
didPass: true,
|
|
138
|
+
message: "Every service.json has a supported schemaVersion",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const fixes = [
|
|
142
|
+
...(missing.length > 0 ? ["Run `tdk config migrate` to add the missing schemaVersion"] : []),
|
|
143
|
+
...(unsupported.length > 0
|
|
144
|
+
? ["A version this tdk does not support cannot be migrated: upgrade tdk"]
|
|
145
|
+
: []),
|
|
146
|
+
];
|
|
147
|
+
return {
|
|
148
|
+
name: "schemaVersion",
|
|
149
|
+
didPass: false,
|
|
150
|
+
isWarning: !strict,
|
|
151
|
+
message: `${formatCount(missing.length + unsupported.length, "service.json file")} without a supported schemaVersion:\n ${[...missing, ...unsupported].join("\n ")}`,
|
|
152
|
+
fix: fixes.join(". "),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Prints the schemaVersion warning once. It never stops anything: the project keeps working as it is. */
|
|
156
|
+
export function warnSchemaVersions(projectRoot) {
|
|
157
|
+
const check = checkSchemaVersions(projectRoot);
|
|
158
|
+
if (check.didPass)
|
|
159
|
+
return;
|
|
160
|
+
console.warn(chalk.yellow(`⚠ ${check.message}`));
|
|
161
|
+
if (check.fix)
|
|
162
|
+
console.warn(chalk.gray(` ${check.fix}`));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Runs before anything starts (also under --dry-run, like the drift gate): duplicate service names, circular
|
|
166
|
+
* dependsOn and invalid ports exit with 1 and say which file, which field, and how to fix it.
|
|
167
|
+
*/
|
|
168
|
+
export function enforceServiceConfigGate(projectRoot, options = {}, exit = process.exit) {
|
|
169
|
+
const failed = [
|
|
170
|
+
checkDuplicateResourceNames(projectRoot),
|
|
171
|
+
checkDependsOnShape(projectRoot),
|
|
172
|
+
checkCircularDependencies(projectRoot),
|
|
173
|
+
checkServicePorts(projectRoot),
|
|
174
|
+
].filter((check) => !check.didPass);
|
|
175
|
+
if (failed.length === 0)
|
|
176
|
+
return;
|
|
177
|
+
const text = failed
|
|
178
|
+
.map((check) => `${check.message}${check.fix ? `\nFix: ${check.fix}` : ""}`)
|
|
179
|
+
.join("\n");
|
|
180
|
+
options.onInvalid?.(text);
|
|
181
|
+
for (const check of failed) {
|
|
182
|
+
console.error(chalk.red(`✗ ${check.message}`));
|
|
183
|
+
if (check.fix)
|
|
184
|
+
console.error(chalk.gray(` Fix: ${check.fix}`));
|
|
185
|
+
}
|
|
186
|
+
exit(1);
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=service-config-checks.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buildStartupReport, transitiveDependencies } from "./startup-report.js";
|
|
2
|
+
import { isTiltResourcePending } from "./tilt-resource-state.js";
|
|
3
|
+
import { onlyEnabledResources } from "./up-readiness.js";
|
|
4
|
+
/**
|
|
5
|
+
* The one answer to "is this service ready?" for `tdk status` and `tdk ui`, from Tilt's resources and the `dependsOn`
|
|
6
|
+
* in service.json. A service whose own process is up is still not ready while something it depends on has failed or
|
|
7
|
+
* is still starting. Services Tilt does not list are absent: the caller shows them as unknown.
|
|
8
|
+
*/
|
|
9
|
+
export function deriveServiceStates(jsonText, dependsOn, deferred = new Set()) {
|
|
10
|
+
const report = buildStartupReport(jsonText, dependsOn, deferred);
|
|
11
|
+
const failed = new Map(report.failed.map((entry) => [entry.name, entry.message]));
|
|
12
|
+
const blocked = new Map(report.blocked.map((entry) => [entry.name, entry.because]));
|
|
13
|
+
const starting = new Set(report.starting);
|
|
14
|
+
const items = JSON.parse(onlyEnabledResources(jsonText)).items ?? [];
|
|
15
|
+
const direct = new Map();
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
const name = item.metadata?.name;
|
|
18
|
+
if (!name || name === "(Tiltfile)")
|
|
19
|
+
continue;
|
|
20
|
+
if (failed.has(name)) {
|
|
21
|
+
direct.set(name, { status: "error", reason: failed.get(name) });
|
|
22
|
+
}
|
|
23
|
+
else if (blocked.has(name)) {
|
|
24
|
+
const because = blocked.get(name) ?? [];
|
|
25
|
+
direct.set(name, {
|
|
26
|
+
status: "error",
|
|
27
|
+
reason: `${because.join(", ")} failed`,
|
|
28
|
+
blockedBy: because,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else if (starting.has(name)) {
|
|
32
|
+
direct.set(name, { status: "pending" });
|
|
33
|
+
}
|
|
34
|
+
else if (deferred.has(name) &&
|
|
35
|
+
isTiltResourcePending(item.status?.updateStatus ?? "", item.status?.runtimeStatus ?? "")) {
|
|
36
|
+
direct.set(name, { status: "unknown", reason: "starts on its first request" });
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
direct.set(name, { status: "ready" });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const [name, state] of direct) {
|
|
44
|
+
if (state.status !== "ready") {
|
|
45
|
+
result[name] = state;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const deps = [...transitiveDependencies(name, dependsOn)].filter((dep) => direct.has(dep));
|
|
49
|
+
const failedDeps = deps.filter((dep) => direct.get(dep)?.status === "error").sort();
|
|
50
|
+
const waitingDeps = deps.filter((dep) => direct.get(dep)?.status === "pending").sort();
|
|
51
|
+
if (failedDeps.length > 0) {
|
|
52
|
+
result[name] = {
|
|
53
|
+
status: "error",
|
|
54
|
+
reason: `${failedDeps.join(", ")} failed`,
|
|
55
|
+
blockedBy: failedDeps,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
else if (waitingDeps.length > 0) {
|
|
59
|
+
result[name] = { status: "pending", reason: `waiting for ${waitingDeps.join(", ")}` };
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
result[name] = state;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=service-runtime-state.js.map
|
|
@@ -6,6 +6,7 @@ import { errorFactories } from "./errors.js";
|
|
|
6
6
|
import { formatCount } from "./formatting.js";
|
|
7
7
|
import { findProjectRoot } from "./paths.js";
|
|
8
8
|
import { validateServiceManifest } from "./service-manifest.js";
|
|
9
|
+
import { deriveStackStatus } from "./stack-status.js";
|
|
9
10
|
import { includes } from "./validation.js";
|
|
10
11
|
const RESOURCE_JSON_FILENAME = "service.json";
|
|
11
12
|
function findServiceJsonFiles(dir, maxDepth = 5, currentDepth = 0) {
|
|
@@ -338,21 +339,7 @@ export function getStackMetadata(stack) {
|
|
|
338
339
|
const timestamps = resourcesMetadata.map((r) => new Date(r.createdAt).getTime());
|
|
339
340
|
const earliestTimestamp = Math.min(...timestamps);
|
|
340
341
|
const latestTimestamp = Math.max(...timestamps);
|
|
341
|
-
const
|
|
342
|
-
let overallStatus = "unknown";
|
|
343
|
-
if (totalResources > 0) {
|
|
344
|
-
const readyCount = resourcesMetadata.filter((r) => r.status === "ready").length;
|
|
345
|
-
const ratio = readyCount / totalResources;
|
|
346
|
-
if (ratio > 0.9) {
|
|
347
|
-
overallStatus = "healthy";
|
|
348
|
-
}
|
|
349
|
-
else if (ratio > 0.5) {
|
|
350
|
-
overallStatus = "degraded";
|
|
351
|
-
}
|
|
352
|
-
else {
|
|
353
|
-
overallStatus = "error";
|
|
354
|
-
}
|
|
355
|
-
}
|
|
342
|
+
const overallStatus = deriveStackStatus(resourcesMetadata.map((r) => r.status));
|
|
356
343
|
const metadata = {
|
|
357
344
|
name: stack.name,
|
|
358
345
|
resourceCount: resourcesMetadata.length,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a load and applies its result, but starts nothing while a previous load is still running. A poll on a timer
|
|
3
|
+
* therefore never overlaps itself: results cannot arrive out of order, and a slow Tilt cannot make every poll get
|
|
4
|
+
* superseded before it finishes.
|
|
5
|
+
*/
|
|
6
|
+
export function singleFlight(apply) {
|
|
7
|
+
let running = false;
|
|
8
|
+
return async (load) => {
|
|
9
|
+
if (running)
|
|
10
|
+
return;
|
|
11
|
+
running = true;
|
|
12
|
+
try {
|
|
13
|
+
apply(await load());
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
running = false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=single-flight.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stack with no known service status has not been checked, so it is "unknown", not "error". Once any service has a
|
|
3
|
+
* status, the share of ready services decides it.
|
|
4
|
+
*/
|
|
5
|
+
export function deriveStackStatus(statuses) {
|
|
6
|
+
if (statuses.length === 0 || statuses.every((status) => status === "unknown"))
|
|
7
|
+
return "unknown";
|
|
8
|
+
const ratio = statuses.filter((status) => status === "ready").length / statuses.length;
|
|
9
|
+
if (ratio > 0.9)
|
|
10
|
+
return "healthy";
|
|
11
|
+
if (ratio > 0.5)
|
|
12
|
+
return "degraded";
|
|
13
|
+
return "error";
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=stack-status.js.map
|