@tdk-landscape/tdk-cli-core 1.3.121 → 1.3.123
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 +13 -8
- package/cli/dist/cli.js +1 -1
- package/cli/dist/commands/doctor.js +64 -7
- package/cli/dist/commands/help.js +17 -1
- package/cli/dist/commands/logs.js +28 -7
- package/cli/dist/commands/stack.js +94 -22
- package/cli/dist/commands/ui.js +81 -6
- package/cli/dist/commands/up.js +17 -3
- package/cli/dist/components/TabBar.js +3 -2
- package/cli/dist/generator/__tests__/golden-helpers.js +42 -0
- package/cli/dist/generator/template-engine.js +1 -1
- 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 +4 -1
- package/cli/dist/utils/doctor-wiring.js +62 -1
- package/cli/dist/utils/host-port-plan.js +3 -0
- package/cli/dist/utils/port-conflict-fix.js +24 -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/startup-report.js +6 -1
- package/cli/dist/utils/tilt-events.js +195 -0
- package/cli/dist/utils/tilt-unreachable-message.js +8 -0
- package/cli/package.json +1 -1
- package/cli/templates/TILT_TECH_STACK.star.hbs +4 -4
- 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
|
@@ -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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseTiltResourceFailures, summarizeTiltBuildError } from "./doctor-runtime.js";
|
|
2
|
+
import { portConflictFix } from "./port-conflict-fix.js";
|
|
2
3
|
import { isTiltResourcePending } from "./tilt-resource-state.js";
|
|
3
4
|
import { onlyEnabledResources } from "./up-readiness.js";
|
|
4
5
|
export function transitiveDependencies(name, dependsOn) {
|
|
@@ -52,8 +53,12 @@ export function isStartupStalled(jsonText, dependsOn, deferred = new Set()) {
|
|
|
52
53
|
}
|
|
53
54
|
export function formatStartupReport(report, timedOut) {
|
|
54
55
|
const lines = [];
|
|
55
|
-
for (const { name, message } of report.failed)
|
|
56
|
+
for (const { name, message } of report.failed) {
|
|
56
57
|
lines.push(`✗ ${name} failed: ${message}`);
|
|
58
|
+
const fix = portConflictFix(message);
|
|
59
|
+
if (fix)
|
|
60
|
+
lines.push(` ${fix}`);
|
|
61
|
+
}
|
|
57
62
|
for (const { name, because } of report.blocked) {
|
|
58
63
|
lines.push(`✗ ${name} is not ready because ${because.join(", ")} failed`);
|
|
59
64
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { STANDARD_PORTS } from "./constants.js";
|
|
2
|
+
import { runTilt } from "./tilt.js";
|
|
3
|
+
export class TiltEventsLoadError extends Error {
|
|
4
|
+
kind;
|
|
5
|
+
constructor(message, kind) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
this.name = "TiltEventsLoadError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function asRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
13
|
+
? value
|
|
14
|
+
: null;
|
|
15
|
+
}
|
|
16
|
+
// Tilt-controlled text is rendered in the terminal, so drop ANSI/OSC escape sequences and
|
|
17
|
+
// other control characters (keeping tab/newline for later whitespace normalization).
|
|
18
|
+
const ESC = String.fromCharCode(0x1b);
|
|
19
|
+
const BEL = String.fromCharCode(0x07);
|
|
20
|
+
const ANSI_SEQUENCE = new RegExp(`${ESC}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)?|[@-Z\\\\-_])`, "g");
|
|
21
|
+
// A regex literal would trip noControlCharactersInRegex, so this stays a string; keep the autofix from rewriting it.
|
|
22
|
+
// biome-ignore lint/complexity/useRegexLiterals: the control-character range must stay in a string
|
|
23
|
+
const CONTROL_CHARS = new RegExp("[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f]", "g");
|
|
24
|
+
export function stripTerminalControls(value) {
|
|
25
|
+
return value.replace(ANSI_SEQUENCE, "").replace(CONTROL_CHARS, "");
|
|
26
|
+
}
|
|
27
|
+
function readString(value) {
|
|
28
|
+
if (typeof value !== "string")
|
|
29
|
+
return undefined;
|
|
30
|
+
const cleaned = stripTerminalControls(value).trim();
|
|
31
|
+
return cleaned || undefined;
|
|
32
|
+
}
|
|
33
|
+
function readStringArray(value) {
|
|
34
|
+
return Array.isArray(value)
|
|
35
|
+
? value.flatMap((item) => {
|
|
36
|
+
const cleaned = readString(item);
|
|
37
|
+
return cleaned ? [cleaned] : [];
|
|
38
|
+
})
|
|
39
|
+
: [];
|
|
40
|
+
}
|
|
41
|
+
export function parseTiltUiResourceList(value) {
|
|
42
|
+
const list = asRecord(value);
|
|
43
|
+
if (!list || !Array.isArray(list.items)) {
|
|
44
|
+
throw new Error("Tilt returned an invalid UIResource list");
|
|
45
|
+
}
|
|
46
|
+
const resources = [];
|
|
47
|
+
const events = [];
|
|
48
|
+
for (const value of list.items) {
|
|
49
|
+
const item = asRecord(value);
|
|
50
|
+
const metadata = asRecord(item?.metadata);
|
|
51
|
+
const resourceName = readString(metadata?.name);
|
|
52
|
+
if (!item || !resourceName)
|
|
53
|
+
continue;
|
|
54
|
+
const status = asRecord(item.status);
|
|
55
|
+
resources.push({
|
|
56
|
+
name: resourceName,
|
|
57
|
+
runtimeStatus: readString(status?.runtimeStatus),
|
|
58
|
+
updateStatus: readString(status?.updateStatus),
|
|
59
|
+
hasPendingChanges: typeof status?.hasPendingChanges === "boolean" ? status.hasPendingChanges : undefined,
|
|
60
|
+
});
|
|
61
|
+
const history = Array.isArray(status?.buildHistory) ? status.buildHistory : [];
|
|
62
|
+
for (const [buildIndex, value] of history.entries()) {
|
|
63
|
+
const build = asRecord(value);
|
|
64
|
+
if (!build)
|
|
65
|
+
continue;
|
|
66
|
+
const error = readString(build.error);
|
|
67
|
+
const warnings = readStringArray(build.warnings);
|
|
68
|
+
const details = [error, ...warnings].filter((entry) => Boolean(entry));
|
|
69
|
+
const spanId = readString(build.spanID);
|
|
70
|
+
events.push({
|
|
71
|
+
id: `${resourceName}:build:${spanId ?? String(buildIndex)}`,
|
|
72
|
+
resourceName,
|
|
73
|
+
kind: error ? "failed" : warnings.length ? "warning" : "success",
|
|
74
|
+
title: error
|
|
75
|
+
? "Build failed"
|
|
76
|
+
: warnings.length
|
|
77
|
+
? "Build completed with warnings"
|
|
78
|
+
: "Build completed",
|
|
79
|
+
occurredAt: readString(build.finishTime) ?? readString(build.startTime),
|
|
80
|
+
details: details.length ? details.join("; ") : undefined,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const currentBuild = asRecord(status?.currentBuild);
|
|
84
|
+
if (currentBuild) {
|
|
85
|
+
events.push({
|
|
86
|
+
id: `${resourceName}:current-build`,
|
|
87
|
+
resourceName,
|
|
88
|
+
kind: "running",
|
|
89
|
+
title: "Build in progress",
|
|
90
|
+
occurredAt: readString(currentBuild.startTime),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const pendingSince = readString(status?.pendingBuildSince);
|
|
94
|
+
if (!currentBuild && (status?.queued === true || pendingSince)) {
|
|
95
|
+
events.push({
|
|
96
|
+
id: `${resourceName}:queued`,
|
|
97
|
+
resourceName,
|
|
98
|
+
kind: "warning",
|
|
99
|
+
title: "Update queued",
|
|
100
|
+
occurredAt: pendingSince,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const lastDeployTime = readString(status?.lastDeployTime);
|
|
104
|
+
const deployTimestamp = lastDeployTime ? Date.parse(lastDeployTime) : Number.NaN;
|
|
105
|
+
const matchingBuild = events.some((event) => event.resourceName === resourceName &&
|
|
106
|
+
event.kind === "success" &&
|
|
107
|
+
event.occurredAt !== undefined &&
|
|
108
|
+
Date.parse(event.occurredAt) === deployTimestamp);
|
|
109
|
+
if (lastDeployTime && !matchingBuild) {
|
|
110
|
+
events.push({
|
|
111
|
+
id: `${resourceName}:last-deploy`,
|
|
112
|
+
resourceName,
|
|
113
|
+
kind: "success",
|
|
114
|
+
title: "Last deployed",
|
|
115
|
+
occurredAt: lastDeployTime,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const conditions = Array.isArray(status?.conditions) ? status.conditions : [];
|
|
119
|
+
for (const [conditionIndex, value] of conditions.entries()) {
|
|
120
|
+
const condition = asRecord(value);
|
|
121
|
+
if (!condition)
|
|
122
|
+
continue;
|
|
123
|
+
const type = readString(condition.type);
|
|
124
|
+
const state = readString(condition.status);
|
|
125
|
+
const reason = readString(condition.reason);
|
|
126
|
+
const message = readString(condition.message);
|
|
127
|
+
if ((!type && !reason && !message) || (state === "True" && !reason && !message))
|
|
128
|
+
continue;
|
|
129
|
+
const detail = [reason, message]
|
|
130
|
+
.filter((entry) => Boolean(entry))
|
|
131
|
+
.join(": ");
|
|
132
|
+
events.push({
|
|
133
|
+
id: `${resourceName}:condition:${conditionIndex}:${type ?? "status"}`,
|
|
134
|
+
resourceName,
|
|
135
|
+
kind: state === "False" ? "warning" : "status",
|
|
136
|
+
title: `Condition ${type ?? "status"}${state ? `: ${state}` : ""}`,
|
|
137
|
+
occurredAt: readString(condition.lastTransitionTime),
|
|
138
|
+
details: detail || undefined,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
events.sort((left, right) => {
|
|
143
|
+
if (left.kind === "running" && right.kind !== "running")
|
|
144
|
+
return -1;
|
|
145
|
+
if (right.kind === "running" && left.kind !== "running")
|
|
146
|
+
return 1;
|
|
147
|
+
const leftTime = left.occurredAt ? Date.parse(left.occurredAt) : Number.NaN;
|
|
148
|
+
const rightTime = right.occurredAt ? Date.parse(right.occurredAt) : Number.NaN;
|
|
149
|
+
if (Number.isNaN(leftTime)) {
|
|
150
|
+
return Number.isNaN(rightTime) ? left.resourceName.localeCompare(right.resourceName) : 1;
|
|
151
|
+
}
|
|
152
|
+
if (Number.isNaN(rightTime))
|
|
153
|
+
return -1;
|
|
154
|
+
return rightTime - leftTime;
|
|
155
|
+
});
|
|
156
|
+
return { resources, events };
|
|
157
|
+
}
|
|
158
|
+
export async function loadTiltEvents() {
|
|
159
|
+
const port = process.env.TILT_PORT ?? String(STANDARD_PORTS.tiltUi);
|
|
160
|
+
let result;
|
|
161
|
+
try {
|
|
162
|
+
result = await runTilt("get", ["uiresources", "-o", "json", "--port", port], {
|
|
163
|
+
inheritStdio: false,
|
|
164
|
+
timeoutMs: 10_000,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
const kind = /ENOENT|not found|no such file|connection refused|failed to connect|could not connect|unable to connect|Tilt is not running/i.test(message)
|
|
170
|
+
? "unavailable"
|
|
171
|
+
: "error";
|
|
172
|
+
throw new TiltEventsLoadError(message, kind);
|
|
173
|
+
}
|
|
174
|
+
if (result.exitCode !== 0) {
|
|
175
|
+
const message = result.stderr.trim() || `tilt get exited with code ${result.exitCode}`;
|
|
176
|
+
const kind = /connection refused|failed to connect|could not connect|unable to connect|Tilt is not running|no running Tilt/i.test(message)
|
|
177
|
+
? "unavailable"
|
|
178
|
+
: "error";
|
|
179
|
+
throw new TiltEventsLoadError(message, kind);
|
|
180
|
+
}
|
|
181
|
+
let parsed;
|
|
182
|
+
try {
|
|
183
|
+
parsed = JSON.parse(result.stdout);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
throw new TiltEventsLoadError("Tilt returned invalid UIResource JSON", "error");
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return parseTiltUiResourceList(parsed);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
throw new TiltEventsLoadError(error instanceof Error ? error.message : "Tilt returned an invalid UIResource list", "error");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=tilt-events.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `tdk logs` says when no Tilt answers on the port. The usual cause is that the stack is not up, so the message
|
|
3
|
+
* names the command that starts it; Tilt's own connection error is not repeated, because it only restates that.
|
|
4
|
+
*/
|
|
5
|
+
export function tiltUnreachableMessage(port) {
|
|
6
|
+
return `Could not reach Tilt on port ${port}. The stack is not running, or Tilt is on another port. Start it with: tdk up`;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=tilt-unreachable-message.js.map
|
package/cli/package.json
CHANGED
|
@@ -69,15 +69,15 @@ def validate_tech_stack(config, strict=False):
|
|
|
69
69
|
))
|
|
70
70
|
elif strict:
|
|
71
71
|
errors.append(
|
|
72
|
-
"❌ Tech Stack Error: {}: '{}' is not supported. " +
|
|
73
|
-
"Platform standard is '{}'. Use _override_reason to document exceptions.".format(
|
|
72
|
+
("❌ Tech Stack Error: {}: '{}' is not supported. " +
|
|
73
|
+
"Platform standard is '{}'. Use _override_reason to document exceptions.").format(
|
|
74
74
|
key, value, standard
|
|
75
75
|
)
|
|
76
76
|
)
|
|
77
77
|
else:
|
|
78
78
|
warnings.append(
|
|
79
|
-
"⚠️ Tech Stack Warning: {}: '{}' is not standard. " +
|
|
80
|
-
"Platform standard is '{}'.".format(key, value, standard)
|
|
79
|
+
("⚠️ Tech Stack Warning: {}: '{}' is not standard. " +
|
|
80
|
+
"Platform standard is '{}'.").format(key, value, standard)
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
# Optional validations (warnings if missing or different)
|
|
@@ -450,79 +450,6 @@ def _generate_port_anchors(resource_entries):
|
|
|
450
450
|
return "\n".join(anchors)
|
|
451
451
|
|
|
452
452
|
|
|
453
|
-
def _detect_sdk_in_entries(resource_entries):
|
|
454
|
-
"""Check if any entry is an SDK."""
|
|
455
|
-
for entry in resource_entries:
|
|
456
|
-
if "-sdk:" in entry or entry.strip().startswith("identity-sdk:"):
|
|
457
|
-
return True
|
|
458
|
-
return False
|
|
459
|
-
|
|
460
|
-
def _detect_frontend_in_entries(resource_entries):
|
|
461
|
-
"""Check if any entry is a frontend."""
|
|
462
|
-
for entry in resource_entries:
|
|
463
|
-
if "<<: *frontend-memory-limit" in entry:
|
|
464
|
-
return True
|
|
465
|
-
return False
|
|
466
|
-
|
|
467
|
-
def _detect_backend_in_entries(resource_entries):
|
|
468
|
-
"""Check if any entry is a backend."""
|
|
469
|
-
for entry in resource_entries:
|
|
470
|
-
if _is_backend_entry(entry):
|
|
471
|
-
return True
|
|
472
|
-
return False
|
|
473
|
-
|
|
474
|
-
def _is_sdk_entry(entry):
|
|
475
|
-
"""Check if a specific entry is an SDK."""
|
|
476
|
-
return "-sdk:" in entry or entry.strip().startswith("identity-sdk:")
|
|
477
|
-
|
|
478
|
-
def _is_backend_entry(entry):
|
|
479
|
-
"""Check if a specific entry is a backend (not frontend, not SDK)."""
|
|
480
|
-
if "-sdk:" in entry or entry.strip().startswith("identity-sdk:"):
|
|
481
|
-
return False
|
|
482
|
-
if "<<: *frontend-memory-limit" in entry:
|
|
483
|
-
return False
|
|
484
|
-
# Backend entries have healthcheck with backend port pattern
|
|
485
|
-
if "healthcheck:" in entry and "curl" in entry:
|
|
486
|
-
return True
|
|
487
|
-
return False
|
|
488
|
-
|
|
489
|
-
def _replace_ports_with_anchors(entry, has_sdk=False, has_frontend=False, has_backend=False):
|
|
490
|
-
"""Replace hardcoded ports with YAML anchors in an entry."""
|
|
491
|
-
is_sdk = _is_sdk_entry(entry)
|
|
492
|
-
is_backend = _is_backend_entry(entry)
|
|
493
|
-
lines = entry.split("\n")
|
|
494
|
-
result = []
|
|
495
|
-
|
|
496
|
-
for line in lines:
|
|
497
|
-
new_line = line
|
|
498
|
-
# NOTE: We do NOT replace PORT=3000 with an anchor because YAML anchors
|
|
499
|
-
# in environment variable values are treated as literal strings, not resolved.
|
|
500
|
-
# The actual port value is already correctly set by _generate_single_backend_entry
|
|
501
|
-
# using the internal_port variable. Keep PORT=3000 as-is.
|
|
502
|
-
|
|
503
|
-
# YAML anchors do not resolve inside quoted Docker labels. Traefik must
|
|
504
|
-
# see a numeric loadbalancer.port (e.g. 4000), not `*backend-port`.
|
|
505
|
-
# NOTE: We do NOT replace healthcheck URLs with YAML anchors because anchors
|
|
506
|
-
# don't work inside quoted strings. The wget healthcheck URLs must use actual
|
|
507
|
-
# port numbers (e.g., http://127.0.0.1:3000), not anchor references.
|
|
508
|
-
# The correct port is already set by generate_frontend_compose.
|
|
509
|
-
# NOTE: We do NOT replace healthcheck URLs with anchors because YAML anchors
|
|
510
|
-
# don't work inside quoted strings. The healthcheck URL must use the actual
|
|
511
|
-
# port number (e.g., http://localhost:3000/health), not an anchor reference.
|
|
512
|
-
# The _generate_single_backend_entry function already uses the correct port
|
|
513
|
-
# via the internal_port variable in the healthcheck URL.
|
|
514
|
-
|
|
515
|
-
result.append(new_line)
|
|
516
|
-
|
|
517
|
-
return "\n".join(result)
|
|
518
|
-
|
|
519
|
-
def _process_entries_with_anchors(resource_entries, has_sdk, has_frontend, has_backend):
|
|
520
|
-
"""Replace ports with anchors in all entries."""
|
|
521
|
-
result = []
|
|
522
|
-
for entry in resource_entries:
|
|
523
|
-
result.append(_replace_ports_with_anchors(entry, has_sdk, has_frontend, has_backend))
|
|
524
|
-
return result
|
|
525
|
-
|
|
526
453
|
def generate_app_compose_from_entries(resource_path, resource_entries, write_fn=None):
|
|
527
454
|
"""Generate docker-compose.app.autogenerated.yml from service entries with port anchors.
|
|
528
455
|
|
|
@@ -535,14 +462,6 @@ def generate_app_compose_from_entries(resource_path, resource_entries, write_fn=
|
|
|
535
462
|
str: Generated docker-compose YAML content
|
|
536
463
|
"""
|
|
537
464
|
|
|
538
|
-
# Detect service types
|
|
539
|
-
has_sdk = _detect_sdk_in_entries(resource_entries)
|
|
540
|
-
has_frontend = _detect_frontend_in_entries(resource_entries)
|
|
541
|
-
has_backend = _detect_backend_in_entries(resource_entries)
|
|
542
|
-
|
|
543
|
-
# Replace ports with anchors in entries
|
|
544
|
-
processed_entries = _process_entries_with_anchors(resource_entries, has_sdk, has_frontend, has_backend)
|
|
545
|
-
|
|
546
465
|
# Generate port anchors
|
|
547
466
|
port_anchors = _generate_port_anchors(resource_entries)
|
|
548
467
|
|
|
@@ -598,15 +517,11 @@ networks:
|
|
|
598
517
|
network_infisical=PlatformDockerConstants.NETWORK_INFISICAL,
|
|
599
518
|
)
|
|
600
519
|
|
|
601
|
-
content = header + "\n".join(
|
|
520
|
+
content = header + "\n".join(resource_entries) + footer
|
|
602
521
|
|
|
603
522
|
if write_fn:
|
|
604
523
|
compose_path = resource_path + '/' + OUTPUT.AUTOGENERATED_FOLDER + '/docker-compose.app.autogenerated.yml'
|
|
605
|
-
print("DEBUG COMPOSE GEN: Writing {} bytes to {}".format(len(content), compose_path))
|
|
606
524
|
write_fn(compose_path, content)
|
|
607
|
-
print("DEBUG COMPOSE GEN: Write completed")
|
|
608
|
-
else:
|
|
609
|
-
print("DEBUG COMPOSE GEN: No write_fn provided!")
|
|
610
525
|
|
|
611
526
|
return content
|
|
612
527
|
|
|
@@ -10,7 +10,7 @@ load("../config/healthcheck.star", "compose_healthcheck_timing")
|
|
|
10
10
|
load("../networking/traefik_static_routes.star", "normalize_abs_path")
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def generate_standalone_traefik_compose(sablier_enabled = False, http_host_port = "8080", https_host_port = "8443"):
|
|
13
|
+
def generate_standalone_traefik_compose(sablier_enabled = False, http_host_port = "8080", https_host_port = "8443", bind_address = "127.0.0.1"):
|
|
14
14
|
"""Minimal Traefik that routes Docker labels on api.{project}.localhost.
|
|
15
15
|
|
|
16
16
|
The Sablier plugin, the sablier container and the wake gateway are only
|
|
@@ -31,7 +31,6 @@ services:
|
|
|
31
31
|
container_name: {name}_traefik
|
|
32
32
|
restart: unless-stopped
|
|
33
33
|
{traefik_depends_on} command:
|
|
34
|
-
- "--api.insecure=true"
|
|
35
34
|
- "--ping=true"
|
|
36
35
|
- "--providers.docker=true"
|
|
37
36
|
- "--providers.docker.exposedbydefault=false"
|
|
@@ -48,8 +47,8 @@ services:
|
|
|
48
47
|
- "--entrypoints.websecure.address=:443"
|
|
49
48
|
- "--log.level=INFO"
|
|
50
49
|
{sablier_flags} ports:
|
|
51
|
-
- "{http_host_port}:80"
|
|
52
|
-
- "{https_host_port}:443"
|
|
50
|
+
- "{bind_address}:{http_host_port}:80"
|
|
51
|
+
- "{bind_address}:{https_host_port}:443"
|
|
53
52
|
volumes:
|
|
54
53
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
55
54
|
- ./{dynamic_dir_name}:/etc/traefik/dynamic:ro
|
|
@@ -67,6 +66,7 @@ services:
|
|
|
67
66
|
network=network,
|
|
68
67
|
http_host_port=http_host_port,
|
|
69
68
|
https_host_port=https_host_port,
|
|
69
|
+
bind_address=bind_address,
|
|
70
70
|
healthcheck_timing=compose_healthcheck_timing(10),
|
|
71
71
|
traefik_depends_on=_SABLIER_DEPENDS_ON if sablier_enabled else "",
|
|
72
72
|
sablier_flags=_SABLIER_FLAGS if sablier_enabled else "",
|
|
@@ -83,6 +83,22 @@ def get_frontend_traefik_labels(res_name, domain, base_path, port, traefik_host=
|
|
|
83
83
|
|
|
84
84
|
return labels
|
|
85
85
|
|
|
86
|
+
def _stack_is_shared(manifest):
|
|
87
|
+
"""True when this backend shares its stack with another routable backend (`_stackBackendCount`, set by register_compose_resources).
|
|
88
|
+
|
|
89
|
+
The stack-scoped routers (the host/`pathPrefix` router and `<name>-management`) carry one rule per stack. With two backends in
|
|
90
|
+
one stack they would carry the same rule, so Traefik would pick between them arbitrarily. Each backend keeps its own
|
|
91
|
+
`<name>-project` router (`/api/<name>`).
|
|
92
|
+
"""
|
|
93
|
+
return bool(manifest) and manifest.get('_stackBackendCount', 1) > 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _explicit_backend_route(manifest):
|
|
97
|
+
"""The `traefik.host` / `traefik.pathPrefix` a manifest set on purpose, as (host, path); either may be empty."""
|
|
98
|
+
traefik_cfg = (manifest.get('traefik') or {}) if manifest else {}
|
|
99
|
+
return traefik_cfg.get('host') or '', traefik_cfg.get('pathPrefix') or ''
|
|
100
|
+
|
|
101
|
+
|
|
86
102
|
def get_backend_traefik_labels(
|
|
87
103
|
resource_entry_name,
|
|
88
104
|
traefik_host,
|
|
@@ -123,12 +139,31 @@ def get_backend_traefik_labels(
|
|
|
123
139
|
# No middleware if path is empty - router has no middlewares
|
|
124
140
|
middleware_config = ""
|
|
125
141
|
|
|
126
|
-
|
|
127
|
-
|
|
142
|
+
stack_shared = _stack_is_shared(manifest)
|
|
143
|
+
explicit_host, explicit_path = _explicit_backend_route(manifest)
|
|
144
|
+
if stack_shared:
|
|
145
|
+
# Another backend in this stack would match the same stack rule, so it is not emitted. The service labels stay (the
|
|
146
|
+
# -project router points at the service). A manifest that set its own host or pathPrefix keeps a router, but one whose
|
|
147
|
+
# rule is only what it set, never the shared stack host or path.
|
|
148
|
+
backend_route_rule = backend_rule(explicit_host, explicit_path)
|
|
149
|
+
|
|
150
|
+
if stack_shared and not backend_route_rule:
|
|
151
|
+
backend_router_labels = ""
|
|
152
|
+
else:
|
|
153
|
+
backend_router_labels = """ - "traefik.http.routers.{resource_entry_name}.rule={backend_route_rule}"
|
|
128
154
|
- "traefik.http.routers.{resource_entry_name}.entrypoints={backend_entrypoints}"
|
|
129
155
|
- "traefik.http.routers.{resource_entry_name}.service={traefik_resource_name}"
|
|
130
156
|
{middleware_config}
|
|
131
|
-
|
|
157
|
+
""".format(
|
|
158
|
+
resource_entry_name=resource_entry_name,
|
|
159
|
+
backend_route_rule=backend_route_rule,
|
|
160
|
+
backend_entrypoints=backend_entrypoints,
|
|
161
|
+
traefik_resource_name=traefik_resource_name,
|
|
162
|
+
middleware_config=middleware_config,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
labels = """ - "{traefik_enable_label}"
|
|
166
|
+
{backend_router_labels} - "traefik.http.services.{traefik_resource_name}.loadbalancer.server.port={internal_port}"
|
|
132
167
|
- "traefik.http.services.{traefik_resource_name}.loadbalancer.healthcheck.path={health_path}"
|
|
133
168
|
- "traefik.http.services.{traefik_resource_name}.loadbalancer.healthcheck.interval={health_interval}"
|
|
134
169
|
- "traefik.http.services.{traefik_resource_name}.loadbalancer.healthcheck.timeout={health_timeout}"
|
|
@@ -137,10 +172,8 @@ def get_backend_traefik_labels(
|
|
|
137
172
|
""".format(
|
|
138
173
|
resource_entry_name=resource_entry_name,
|
|
139
174
|
traefik_enable_label=TRAEFIK_ENABLE_LABEL,
|
|
140
|
-
|
|
141
|
-
backend_entrypoints=backend_entrypoints,
|
|
175
|
+
backend_router_labels=backend_router_labels,
|
|
142
176
|
traefik_resource_name=traefik_resource_name,
|
|
143
|
-
middleware_config=middleware_config,
|
|
144
177
|
internal_port=internal_port,
|
|
145
178
|
health_path=health_path,
|
|
146
179
|
health_interval=TRAEFIK_HEALTHCHECK_INTERVAL,
|
|
@@ -188,7 +221,7 @@ def get_backend_traefik_labels(
|
|
|
188
221
|
router_priority=router_priority,
|
|
189
222
|
)
|
|
190
223
|
|
|
191
|
-
if management_path and management_path != api_path:
|
|
224
|
+
if management_path and management_path != api_path and not stack_shared:
|
|
192
225
|
labels += """
|
|
193
226
|
- "traefik.http.routers.{resource_entry_name}-management.rule=Host(`{api_host}`) && PathPrefix(`{management_path}`)"
|
|
194
227
|
- "traefik.http.routers.{resource_entry_name}-management.entrypoints={project_entrypoints}"
|