@tdk-landscape/tdk-cli-core 1.3.91 → 1.3.92
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/cli/dist/backend-frameworks/mcp.js +87 -0
- package/cli/dist/backend-frameworks/registry.js +16 -0
- package/cli/dist/commands/completion.js +3 -3
- package/cli/dist/commands/doctor.js +8 -2
- package/cli/dist/commands/networks.js +2 -1
- package/cli/dist/commands/project.js +15 -2
- package/cli/dist/commands/resource.js +19 -13
- package/cli/dist/commands/up.js +37 -1
- package/cli/dist/types/index.js +1 -0
- package/cli/dist/utils/constants.js +4 -0
- package/cli/dist/utils/doctor-runtime.js +3 -2
- package/cli/dist/utils/doctor-wiring.js +2 -1
- package/cli/dist/utils/env-validator.js +118 -30
- package/cli/dist/utils/resource-kind.js +8 -0
- package/cli/dist/utils/service-manifest.js +23 -3
- package/cli/dist/utils/service-urls.js +4 -3
- package/cli/dist/utils/services.js +2 -1
- package/cli/dist/utils/smoke.js +225 -0
- package/cli/package.json +1 -1
- package/engine/schemas/service-schema.json +73 -27
- package/engine/topologies/platform/docker/auth/auth.star +66 -28
- package/engine/topologies/platform/docker/compose/compose.star +8 -26
- package/engine/topologies/platform/docker/layers/l3_builder_layers.star +3 -0
- package/engine/topologies/platform/docker/layers/l4_runtime_layers.star +3 -0
- package/engine/topologies/platform/docker/networking/api_path_constants.star +1 -57
- package/engine/topologies/platform/docker/secrets/env_secrets.star +72 -0
- package/engine/topologies/tilt/common/utils.star +0 -2
- package/engine/topologies/tilt/common/utils_env.star +72 -25
- package/engine/topologies/tilt/manifest/constants.star +2 -0
- package/engine/topologies/tilt/manifest/json_to_yaml.py +4 -0
- package/engine/topologies/tilt/manifest/loader.star +7 -0
- package/engine/topologies/tilt/resources/orchestrator/generators/env.star +43 -96
- package/package.json +1 -1
|
@@ -5,7 +5,8 @@ const REQUIRED_ENV_VARS = [
|
|
|
5
5
|
{
|
|
6
6
|
name: "VERDACCIO_URL_DOCKER",
|
|
7
7
|
description: "Docker-internal URL of the private npm registry (Verdaccio, a paid feature)",
|
|
8
|
-
|
|
8
|
+
// Verdaccio is a premium, off-by-default service, so a free project must not need its URL.
|
|
9
|
+
required: false,
|
|
9
10
|
default: "http://verdaccio:4873",
|
|
10
11
|
example: "http://verdaccio:4873",
|
|
11
12
|
},
|
|
@@ -21,12 +22,13 @@ const REQUIRED_ENV_VARS = [
|
|
|
21
22
|
required: true,
|
|
22
23
|
default: "dev",
|
|
23
24
|
example: "dev",
|
|
25
|
+
complete: true,
|
|
24
26
|
},
|
|
25
27
|
{
|
|
26
28
|
name: "DATABASE_URL",
|
|
27
|
-
description: "PostgreSQL connection string",
|
|
29
|
+
description: "PostgreSQL connection string (uses the generated DB_PASSWORD below)",
|
|
28
30
|
required: false,
|
|
29
|
-
|
|
31
|
+
// Built from DB_PASSWORD in generateEnvFile(); there is no shared literal password.
|
|
30
32
|
},
|
|
31
33
|
{
|
|
32
34
|
name: "DB_PASSWORD",
|
|
@@ -36,34 +38,88 @@ const REQUIRED_ENV_VARS = [
|
|
|
36
38
|
// credential leak waiting to happen. generateEnvFile() fills this one in with
|
|
37
39
|
// a fresh random value per project instead.
|
|
38
40
|
example: "postgres",
|
|
41
|
+
complete: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "JWT_SECRET",
|
|
45
|
+
description: "Secret that signs local-jwt tokens (generated once per project; never shared between projects)",
|
|
46
|
+
// Not required for `tdk doctor`: `tdk up` adds it to an older project's .env before starting anything.
|
|
47
|
+
required: false,
|
|
48
|
+
complete: true,
|
|
39
49
|
},
|
|
40
50
|
];
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
const generateDbPassword = () => randomBytes(16).toString("hex");
|
|
52
|
+
const generateJwtSecret = () => randomBytes(32).toString("hex");
|
|
53
|
+
function generatedValue(name, password) {
|
|
54
|
+
if (name === "DB_PASSWORD")
|
|
55
|
+
return password;
|
|
56
|
+
if (name === "JWT_SECRET")
|
|
57
|
+
return generateJwtSecret();
|
|
58
|
+
if (name === "DATABASE_URL")
|
|
59
|
+
return `postgresql://postgres:${password}@postgres:5432/app_dev`;
|
|
60
|
+
return REQUIRED_ENV_VARS.find((v) => v.name === name)?.default ?? "";
|
|
61
|
+
}
|
|
62
|
+
/** Strips one layer of matching quotes, like a dotenv parser would. */
|
|
63
|
+
function unquote(raw) {
|
|
64
|
+
const value = raw.trim();
|
|
65
|
+
if (value.length >= 2) {
|
|
66
|
+
const first = value[0];
|
|
67
|
+
if ((first === '"' || first === "'") && value[value.length - 1] === first) {
|
|
68
|
+
return value.slice(1, -1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
/** Parses the assignments in a dotenv file: `NAME=value` and `export NAME=value`, ignoring comments and blank lines. */
|
|
74
|
+
export function parseEnv(content) {
|
|
75
|
+
const result = new Map();
|
|
76
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
77
|
+
const line = rawLine.trim();
|
|
78
|
+
if (!line || line.startsWith("#"))
|
|
79
|
+
continue;
|
|
80
|
+
const assignment = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
81
|
+
const equals = assignment.indexOf("=");
|
|
82
|
+
if (equals <= 0)
|
|
83
|
+
continue;
|
|
84
|
+
const name = assignment.slice(0, equals).trim();
|
|
85
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
86
|
+
continue;
|
|
87
|
+
result.set(name, unquote(assignment.slice(equals + 1)));
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
43
90
|
}
|
|
44
|
-
|
|
91
|
+
function describe(envVar) {
|
|
92
|
+
const lines = [`# ${envVar.description}`];
|
|
93
|
+
if (envVar.required)
|
|
94
|
+
lines.push("# Required (the value below is a working default)");
|
|
95
|
+
if (envVar.example)
|
|
96
|
+
lines.push(`# Example: ${envVar.example}`);
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
export function generateEnvFile() {
|
|
100
|
+
const password = generateDbPassword();
|
|
45
101
|
const lines = [
|
|
46
102
|
"# 🔧 TDK Environment Configuration",
|
|
47
103
|
"# Generated by `tdk project`. The defaults work as-is; edit only to change them.",
|
|
48
|
-
"# Keep this file out of git
|
|
104
|
+
"# Keep this file out of git: it holds a generated database password and JWT secret.",
|
|
49
105
|
"#",
|
|
50
106
|
"# After editing, run: tdk config regenerate && tdk up",
|
|
51
107
|
"",
|
|
52
108
|
];
|
|
53
109
|
for (const envVar of REQUIRED_ENV_VARS) {
|
|
54
|
-
lines.push(
|
|
55
|
-
|
|
56
|
-
lines.push("# Required (the value below is a working default)");
|
|
57
|
-
}
|
|
58
|
-
if (envVar.example) {
|
|
59
|
-
lines.push(`# Example: ${envVar.example}`);
|
|
60
|
-
}
|
|
61
|
-
const value = envVar.name === "DB_PASSWORD" ? generateDbPassword() : envVar.default || "";
|
|
62
|
-
lines.push(`${envVar.name}=${value}`);
|
|
110
|
+
lines.push(...describe(envVar));
|
|
111
|
+
lines.push(`${envVar.name}=${generatedValue(envVar.name, password)}`);
|
|
63
112
|
lines.push("");
|
|
64
113
|
}
|
|
65
114
|
return lines.join("\n");
|
|
66
115
|
}
|
|
116
|
+
/** The password a postgres-style URL carries, or undefined when there is none or it is a variable reference. */
|
|
117
|
+
function urlPassword(url) {
|
|
118
|
+
const match = url.match(/^[a-z][a-z0-9+.-]*:\/\/[^:/@\s]+:([^@\s]*)@/i);
|
|
119
|
+
if (!match || match[1].includes("$"))
|
|
120
|
+
return undefined;
|
|
121
|
+
return match[1];
|
|
122
|
+
}
|
|
67
123
|
export function validateEnvFile(projectRoot) {
|
|
68
124
|
const envPath = join(projectRoot, ".env");
|
|
69
125
|
const result = {
|
|
@@ -78,30 +134,62 @@ export function validateEnvFile(projectRoot) {
|
|
|
78
134
|
warnings: [".env file not found - will be auto-generated"],
|
|
79
135
|
};
|
|
80
136
|
}
|
|
81
|
-
const
|
|
82
|
-
const envVars = new Set(envContent
|
|
83
|
-
.split("\n")
|
|
84
|
-
.filter((line) => !line.startsWith("#") && line.includes("="))
|
|
85
|
-
.map((line) => line.split("=")[0])
|
|
86
|
-
.filter((name) => name.length > 0));
|
|
137
|
+
const env = parseEnv(readFileSync(envPath, "utf-8"));
|
|
87
138
|
for (const envVar of REQUIRED_ENV_VARS) {
|
|
88
|
-
if (envVar.required && !
|
|
139
|
+
if (envVar.required && !env.has(envVar.name)) {
|
|
89
140
|
result.missing.push(envVar.name);
|
|
90
141
|
}
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
if (env.has(envVar.name) && !(env.get(envVar.name) ?? "").trim()) {
|
|
143
|
+
result.invalid.push(`${envVar.name} is set but empty`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const databaseUrl = env.get("DATABASE_URL");
|
|
147
|
+
const dbPassword = env.get("DB_PASSWORD");
|
|
148
|
+
if (databaseUrl && dbPassword) {
|
|
149
|
+
const inUrl = urlPassword(databaseUrl);
|
|
150
|
+
if (inUrl !== undefined && inUrl !== dbPassword) {
|
|
151
|
+
result.warnings.push("DATABASE_URL carries a different password than DB_PASSWORD; the database container uses DB_PASSWORD");
|
|
97
152
|
}
|
|
98
153
|
}
|
|
99
154
|
return result;
|
|
100
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Appends the keys a newer CLI expects to an existing .env, without touching or rotating anything already there (an existing
|
|
158
|
+
* DB_PASSWORD must survive, or the database volume stops accepting it). Creates the file when it is missing.
|
|
159
|
+
*
|
|
160
|
+
* @returns the names that were added, empty when the file was already complete
|
|
161
|
+
*/
|
|
162
|
+
export function completeEnvFile(projectRoot) {
|
|
163
|
+
const envPath = join(projectRoot, ".env");
|
|
164
|
+
if (!existsSync(envPath)) {
|
|
165
|
+
writeFileSync(envPath, generateEnvFile(), { mode: 0o600 });
|
|
166
|
+
return REQUIRED_ENV_VARS.map((v) => v.name);
|
|
167
|
+
}
|
|
168
|
+
const existing = readFileSync(envPath, "utf-8");
|
|
169
|
+
const env = parseEnv(existing);
|
|
170
|
+
const toAdd = REQUIRED_ENV_VARS.filter((v) => v.complete && !env.has(v.name));
|
|
171
|
+
if (toAdd.length === 0)
|
|
172
|
+
return [];
|
|
173
|
+
// Anything generated from the password must use the project's existing one.
|
|
174
|
+
const password = env.get("DB_PASSWORD") || generateDbPassword();
|
|
175
|
+
const lines = [
|
|
176
|
+
"",
|
|
177
|
+
"# Added by TDK: keys this version expects. Existing values above were not changed.",
|
|
178
|
+
"",
|
|
179
|
+
];
|
|
180
|
+
for (const envVar of toAdd) {
|
|
181
|
+
lines.push(...describe(envVar));
|
|
182
|
+
lines.push(`${envVar.name}=${generatedValue(envVar.name, password)}`);
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
186
|
+
writeFileSync(envPath, `${existing}${separator}${lines.join("\n")}`);
|
|
187
|
+
return toAdd.map((v) => v.name);
|
|
188
|
+
}
|
|
101
189
|
export function ensureEnvFile(projectRoot) {
|
|
102
190
|
const envPath = join(projectRoot, ".env");
|
|
103
191
|
if (!existsSync(envPath)) {
|
|
104
|
-
writeFileSync(envPath, generateEnvFile(
|
|
192
|
+
writeFileSync(envPath, generateEnvFile(), { mode: 0o600 });
|
|
105
193
|
return true;
|
|
106
194
|
}
|
|
107
195
|
return false;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True for a resource that runs as an HTTP API service behind the Traefik API host: a backend, or an `mcp` server (a Model Context
|
|
3
|
+
* Protocol endpoint, which the engine builds, routes and health-checks exactly like a backend).
|
|
4
|
+
*/
|
|
5
|
+
export function isApiServiceType(appType) {
|
|
6
|
+
return appType === "backend" || appType === "mcp";
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=resource-kind.js.map
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { validateSmoke } from "./smoke.js";
|
|
2
3
|
export const SERVICE_MANIFEST_SCHEMA_VERSION = 1;
|
|
3
4
|
export const SERVICE_MANIFEST_SCHEMA_URL = "https://tdk-landscape.github.io/schema.service.json";
|
|
5
|
+
/**
|
|
6
|
+
* Fields that are still read, or still tolerated, but should not be used. The warning says what replaces each one. `jwtSecret` is
|
|
7
|
+
* ignored outright: a secret must not be committed (see docs/environment.md).
|
|
8
|
+
*/
|
|
9
|
+
const DEPRECATED_FIELDS = {
|
|
10
|
+
dependencies: "deprecated, use dependsOn (still read for now)",
|
|
11
|
+
envVars: "deprecated, use params (still read when params is absent)",
|
|
12
|
+
jwtSecret: "ignored. A secret must not be committed: remove it and set JWT_SECRET in the project .env",
|
|
13
|
+
};
|
|
4
14
|
const KNOWN_SERVICE_FIELDS = new Set([
|
|
5
15
|
"$schema",
|
|
6
16
|
"apiBasePath",
|
|
@@ -32,6 +42,7 @@ const KNOWN_SERVICE_FIELDS = new Set([
|
|
|
32
42
|
"sablier",
|
|
33
43
|
"schemaVersion",
|
|
34
44
|
"secrets",
|
|
45
|
+
"smoke",
|
|
35
46
|
"stack",
|
|
36
47
|
"syncs",
|
|
37
48
|
"traefik",
|
|
@@ -55,9 +66,18 @@ export function validateServiceManifest(value, displayPath) {
|
|
|
55
66
|
if ("schemaVersion" in manifest && manifest.schemaVersion !== SERVICE_MANIFEST_SCHEMA_VERSION) {
|
|
56
67
|
errors.push(`${displayPath}.schemaVersion: unsupported version ${String(manifest.schemaVersion)} (supported: ${SERVICE_MANIFEST_SCHEMA_VERSION})`);
|
|
57
68
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
if ("smoke" in manifest) {
|
|
70
|
+
for (const message of validateSmoke(manifest.smoke))
|
|
71
|
+
errors.push(`${displayPath}.${message}`);
|
|
72
|
+
}
|
|
73
|
+
const warnings = Object.keys(manifest).flatMap((field) => {
|
|
74
|
+
const deprecation = DEPRECATED_FIELDS[field];
|
|
75
|
+
if (deprecation)
|
|
76
|
+
return [`${displayPath}.${field}: ${deprecation}`];
|
|
77
|
+
if (!KNOWN_SERVICE_FIELDS.has(field))
|
|
78
|
+
return [`${displayPath}.${field}: unknown field is preserved`];
|
|
79
|
+
return [];
|
|
80
|
+
});
|
|
61
81
|
return { errors, warnings, manifest };
|
|
62
82
|
}
|
|
63
83
|
export function validateServiceManifestFile(filePath, displayPath) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { findProjectRoot } from "./paths.js";
|
|
4
|
+
import { isApiServiceType } from "./resource-kind.js";
|
|
4
5
|
export function getProjectName() {
|
|
5
6
|
const root = findProjectRoot();
|
|
6
7
|
if (root) {
|
|
@@ -47,7 +48,7 @@ export function appendHealthPath(path) {
|
|
|
47
48
|
}
|
|
48
49
|
/** The route `tdk up` advertises for a service, without the /health suffix. */
|
|
49
50
|
export function resolveServicePath(resource) {
|
|
50
|
-
if (resource.config?.appType
|
|
51
|
+
if (isApiServiceType(resource.config?.appType)) {
|
|
51
52
|
const servicePathName = resource.name.replace(/-api$/, "");
|
|
52
53
|
return resource.config?.apiPath ?? `/api/${servicePathName}`;
|
|
53
54
|
}
|
|
@@ -61,9 +62,9 @@ export function resolveServicePath(resource) {
|
|
|
61
62
|
export function buildHealthTargets(resources, ingressPort) {
|
|
62
63
|
const { appBase, apiBase } = resolveSubdomainBases(ingressPort);
|
|
63
64
|
return resources
|
|
64
|
-
.filter((resource) => resource.config?.appType === "frontend" || resource.config?.appType
|
|
65
|
+
.filter((resource) => resource.config?.appType === "frontend" || isApiServiceType(resource.config?.appType))
|
|
65
66
|
.map((resource) => {
|
|
66
|
-
const base = resource.config.appType
|
|
67
|
+
const base = isApiServiceType(resource.config.appType) ? apiBase : appBase;
|
|
67
68
|
return {
|
|
68
69
|
name: resource.name,
|
|
69
70
|
appType: resource.config.appType,
|
|
@@ -50,7 +50,8 @@ function parseResource(serviceJsonPath) {
|
|
|
50
50
|
}
|
|
51
51
|
const parsedConfig = parsed;
|
|
52
52
|
for (const warning of validateServiceManifest(parsed, serviceJsonPath).warnings) {
|
|
53
|
-
|
|
53
|
+
// The warning names the file and field and says what is wrong (unknown, deprecated, ignored), so do not prefix it.
|
|
54
|
+
console.warn(`⚠️ ${warning}`);
|
|
54
55
|
}
|
|
55
56
|
const config = {
|
|
56
57
|
...parsedConfig,
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { isApiServiceType } from "./resource-kind.js";
|
|
2
|
+
import { resolveServicePath, resolveSubdomainBases } from "./service-urls.js";
|
|
3
|
+
export const DEFAULT_SMOKE_TIMEOUT_SECONDS = 60;
|
|
4
|
+
const RETRY_DELAY_MS = 1000;
|
|
5
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
6
|
+
const BODY_SNIPPET_CHARS = 200;
|
|
7
|
+
/** Statuses a proxy answers while a route or its upstream is not up yet. Anything else is the service's real answer. */
|
|
8
|
+
const NOT_READY_STATUSES = new Set([404, 502, 503, 504]);
|
|
9
|
+
/** Error codes that prove the request never left this machine, so repeating even a write is safe. */
|
|
10
|
+
const PRE_SEND_ERROR_CODES = new Set([
|
|
11
|
+
"ECONNREFUSED",
|
|
12
|
+
"ENOTFOUND",
|
|
13
|
+
"EAI_AGAIN",
|
|
14
|
+
"EHOSTUNREACH",
|
|
15
|
+
"ENETUNREACH",
|
|
16
|
+
]);
|
|
17
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
/** A thrown request error is retried for safe methods, and for others only when its code shows the request was never sent. */
|
|
22
|
+
function shouldRetryRequestError(err, method) {
|
|
23
|
+
if (SAFE_METHODS.has(method))
|
|
24
|
+
return true;
|
|
25
|
+
if (!isRecord(err))
|
|
26
|
+
return false;
|
|
27
|
+
const cause = isRecord(err.cause) ? err.cause : undefined;
|
|
28
|
+
const code = err.code ?? cause?.code;
|
|
29
|
+
return typeof code === "string" && PRE_SEND_ERROR_CODES.has(code);
|
|
30
|
+
}
|
|
31
|
+
export function validateSmoke(smoke) {
|
|
32
|
+
if (!isRecord(smoke))
|
|
33
|
+
return ['smoke: must be an object with "via" and "steps"'];
|
|
34
|
+
const errors = [];
|
|
35
|
+
if (smoke.via !== "proxy") {
|
|
36
|
+
errors.push('smoke.via: only "proxy" is supported (the check must go through the public URL)');
|
|
37
|
+
}
|
|
38
|
+
if (smoke.timeoutSeconds !== undefined &&
|
|
39
|
+
!(typeof smoke.timeoutSeconds === "number" && smoke.timeoutSeconds > 0)) {
|
|
40
|
+
errors.push("smoke.timeoutSeconds: must be a number greater than 0");
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(smoke.steps) || smoke.steps.length === 0) {
|
|
43
|
+
errors.push("smoke.steps: needs at least one step");
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
46
|
+
smoke.steps.forEach((step, index) => {
|
|
47
|
+
const at = `smoke.steps[${index}]`;
|
|
48
|
+
if (!isRecord(step)) {
|
|
49
|
+
errors.push(`${at}: must be an object`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const path = step.path;
|
|
53
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//")) {
|
|
54
|
+
errors.push(`${at}.path: must start with "/" and is appended to the public URL; a full URL or relative path would skip the proxy`);
|
|
55
|
+
}
|
|
56
|
+
if (step.expect !== undefined &&
|
|
57
|
+
!(typeof step.expect === "number" &&
|
|
58
|
+
Number.isInteger(step.expect) &&
|
|
59
|
+
step.expect >= 100 &&
|
|
60
|
+
step.expect <= 599)) {
|
|
61
|
+
errors.push(`${at}.expect: must be an HTTP status between 100 and 599`);
|
|
62
|
+
}
|
|
63
|
+
if (step.bodyContains !== undefined && typeof step.bodyContains !== "string") {
|
|
64
|
+
errors.push(`${at}.bodyContains: must be a string`);
|
|
65
|
+
}
|
|
66
|
+
if (step.save !== undefined) {
|
|
67
|
+
const ok = isRecord(step.save) &&
|
|
68
|
+
Object.values(step.save).every((v) => typeof v === "string" && v.startsWith("$."));
|
|
69
|
+
if (!ok)
|
|
70
|
+
errors.push(`${at}.save: must map names to paths such as "$.id"`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return errors;
|
|
74
|
+
}
|
|
75
|
+
/** One plan per routable service that declares `smoke`. Workers and BYO with exposeViaProxy false have no public route. */
|
|
76
|
+
export function buildSmokePlans(resources, ingressPort) {
|
|
77
|
+
const { appBase, apiBase } = resolveSubdomainBases(ingressPort);
|
|
78
|
+
const plans = [];
|
|
79
|
+
for (const resource of resources) {
|
|
80
|
+
const config = resource.config;
|
|
81
|
+
if (!config?.smoke)
|
|
82
|
+
continue;
|
|
83
|
+
const appType = config.appType;
|
|
84
|
+
const isApi = isApiServiceType(appType) || appType === "bring-your-own";
|
|
85
|
+
if (!isApi && appType !== "frontend")
|
|
86
|
+
continue;
|
|
87
|
+
if (config.exposeViaProxy === false)
|
|
88
|
+
continue;
|
|
89
|
+
const base = isApi ? apiBase : appBase;
|
|
90
|
+
plans.push({
|
|
91
|
+
name: resource.name,
|
|
92
|
+
baseUrl: `${base}${resolveServicePath(resource).replace(/\/+$/, "")}`,
|
|
93
|
+
smoke: config.smoke,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return plans;
|
|
97
|
+
}
|
|
98
|
+
function readPath(value, path) {
|
|
99
|
+
const parts = path.replace(/^\$\.?/, "").match(/[^.[\]]+/g) ?? [];
|
|
100
|
+
let current = value;
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
if (current === null || current === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
current = current[part];
|
|
105
|
+
}
|
|
106
|
+
return current;
|
|
107
|
+
}
|
|
108
|
+
function fill(template, saved) {
|
|
109
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => saved[key] ?? match);
|
|
110
|
+
}
|
|
111
|
+
function fillBody(body, saved) {
|
|
112
|
+
if (typeof body === "string")
|
|
113
|
+
return fill(body, saved);
|
|
114
|
+
if (Array.isArray(body))
|
|
115
|
+
return body.map((item) => fillBody(item, saved));
|
|
116
|
+
if (isRecord(body)) {
|
|
117
|
+
return Object.fromEntries(Object.entries(body).map(([k, v]) => [k, fillBody(v, saved)]));
|
|
118
|
+
}
|
|
119
|
+
return body;
|
|
120
|
+
}
|
|
121
|
+
function snippet(text) {
|
|
122
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
123
|
+
return flat.length > BODY_SNIPPET_CHARS ? `${flat.slice(0, BODY_SNIPPET_CHARS)}...` : flat;
|
|
124
|
+
}
|
|
125
|
+
/** Runs the steps in order. A not-ready answer (connection error, 404, 502-504) is retried until the timeout; a real wrong answer fails at once. */
|
|
126
|
+
export async function runSmokePlan(plan, deps = {}) {
|
|
127
|
+
const doFetch = deps.fetch ?? ((url, init) => fetch(url, init));
|
|
128
|
+
const now = deps.now ?? Date.now;
|
|
129
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
130
|
+
const deadline = now() + (plan.smoke.timeoutSeconds ?? DEFAULT_SMOKE_TIMEOUT_SECONDS) * 1000;
|
|
131
|
+
const saved = {};
|
|
132
|
+
// A manifest with errors is still loaded, so a malformed block must fail the check, not throw out of `tdk up`.
|
|
133
|
+
const invalid = validateSmoke(plan.smoke);
|
|
134
|
+
if (invalid.length > 0) {
|
|
135
|
+
return {
|
|
136
|
+
name: plan.name,
|
|
137
|
+
ok: false,
|
|
138
|
+
failure: `${plan.name}: invalid smoke block: ${invalid.join("; ")}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
for (const [index, step] of plan.smoke.steps.entries()) {
|
|
142
|
+
const label = step.name ?? `step ${index + 1}`;
|
|
143
|
+
const method = (step.method ?? "GET").toUpperCase();
|
|
144
|
+
const url = `${plan.baseUrl}${fill(step.path, saved)}`;
|
|
145
|
+
const expected = step.expect ?? 200;
|
|
146
|
+
const hasBody = step.body !== undefined;
|
|
147
|
+
let status;
|
|
148
|
+
let text = "";
|
|
149
|
+
let error;
|
|
150
|
+
for (;;) {
|
|
151
|
+
error = undefined;
|
|
152
|
+
status = undefined;
|
|
153
|
+
let retryableError = false;
|
|
154
|
+
try {
|
|
155
|
+
const response = await doFetch(url, {
|
|
156
|
+
method,
|
|
157
|
+
headers: hasBody ? { "content-type": "application/json" } : undefined,
|
|
158
|
+
body: hasBody ? JSON.stringify(fillBody(step.body, saved)) : undefined,
|
|
159
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
160
|
+
});
|
|
161
|
+
status = response.status;
|
|
162
|
+
text = await response.text();
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
retryableError = shouldRetryRequestError(err, method);
|
|
166
|
+
error = err instanceof Error ? err.message : String(err);
|
|
167
|
+
}
|
|
168
|
+
const notReady = (error !== undefined && retryableError) ||
|
|
169
|
+
(status !== undefined && status !== expected && NOT_READY_STATUSES.has(status));
|
|
170
|
+
if (!notReady || now() >= deadline)
|
|
171
|
+
break;
|
|
172
|
+
await sleep(RETRY_DELAY_MS);
|
|
173
|
+
}
|
|
174
|
+
const where = `${plan.name}: ${label}: ${method} ${url}`;
|
|
175
|
+
if (error !== undefined)
|
|
176
|
+
return { name: plan.name, ok: false, failure: `${where} never answered (${error})` };
|
|
177
|
+
if (status !== expected) {
|
|
178
|
+
return {
|
|
179
|
+
name: plan.name,
|
|
180
|
+
ok: false,
|
|
181
|
+
failure: `${where} returned ${status}, expected ${expected}${text ? `: ${snippet(text)}` : ""}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (step.bodyContains !== undefined && !text.includes(step.bodyContains)) {
|
|
185
|
+
return {
|
|
186
|
+
name: plan.name,
|
|
187
|
+
ok: false,
|
|
188
|
+
failure: `${where} returned ${status} but the body does not contain "${step.bodyContains}": ${snippet(text)}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (step.save) {
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return {
|
|
198
|
+
name: plan.name,
|
|
199
|
+
ok: false,
|
|
200
|
+
failure: `${where} returned a body that is not JSON, cannot save: ${snippet(text)}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
for (const [key, path] of Object.entries(step.save)) {
|
|
204
|
+
const value = readPath(parsed, path);
|
|
205
|
+
if (value === undefined || value === null || typeof value === "object") {
|
|
206
|
+
return {
|
|
207
|
+
name: plan.name,
|
|
208
|
+
ok: false,
|
|
209
|
+
failure: `${where} response has no ${path} to save as {{${key}}}: ${snippet(text)}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
saved[key] = String(value);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { name: plan.name, ok: true };
|
|
217
|
+
}
|
|
218
|
+
export function formatSmokeFailure(result) {
|
|
219
|
+
return `Smoke check failed: ${result.failure}`;
|
|
220
|
+
}
|
|
221
|
+
/** Runs every plan side by side; each plan keeps its own timeout. */
|
|
222
|
+
export async function runSmokePlans(plans, deps = {}) {
|
|
223
|
+
return Promise.all(plans.map((plan) => runSmokePlan(plan, deps)));
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=smoke.js.map
|