@tdk-landscape/tdk-cli-core 1.3.50 → 1.3.51
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/README.md +2 -0
- package/cli/package.json +1 -1
- package/docs/demo.svg +217 -0
- package/engine/assets/docker/wake-gateway/Dockerfile +4 -3
- package/engine/assets/docker/wake-gateway/decide.test.ts +47 -1
- package/engine/assets/docker/wake-gateway/decide.ts +32 -2
- package/engine/assets/docker/wake-gateway/server.ts +109 -18
- package/engine/topologies/platform/docker/compose/traefik_standalone.star +23 -1
- package/engine/topologies/platform/docker/networking/traefik_static_routes.star +62 -8
- package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +37 -2
- package/openspec/changes/prioritized-cold-start/design.md +23 -3
- package/openspec/changes/prioritized-cold-start/proposal.md +1 -0
- package/openspec/changes/prioritized-cold-start/tasks.md +11 -1
- package/package.json +1 -1
- package/scripts/record-demo.sh +43 -0
- package/tests/tilt-engine/test_standalone_traefik.py +14 -0
- package/tests/tilt-engine/test_traefik_static_wake_route.py +68 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Wake gateway (see openspec/changes/prioritized-cold-start): fronts every
|
|
2
2
|
# `sablier.deferStart` resource's Traefik static route, triggers cold
|
|
3
3
|
# containers via `tilt trigger` and/or Sablier, then holds the request until
|
|
4
|
-
# healthy. Needs the Docker CLI (client only, talks
|
|
5
|
-
# /var/run/docker.sock
|
|
4
|
+
# healthy. Needs the Docker CLI plus its Compose plugin (client only, talks
|
|
5
|
+
# to the mounted /var/run/docker.sock; compose starts an already-built image
|
|
6
|
+
# directly, ahead of Tilt's build queue) and the real `tilt` CLI (D3: shells out to the
|
|
6
7
|
# documented `tilt trigger` command rather than depending on Tilt's
|
|
7
8
|
# undocumented internal HTTP API).
|
|
8
9
|
|
|
@@ -13,7 +14,7 @@ FROM oven/bun:1.3.11-alpine AS base
|
|
|
13
14
|
# directly (not via tilt-dev's install.sh, which shells out to `sudo` -- not
|
|
14
15
|
# present in this minimal image, and unnecessary since we're already root).
|
|
15
16
|
ARG TILT_VERSION=0.37.7
|
|
16
|
-
RUN apk add --no-cache docker-cli curl gcompat \
|
|
17
|
+
RUN apk add --no-cache docker-cli docker-cli-compose curl gcompat \
|
|
17
18
|
&& arch="$(uname -m)" \
|
|
18
19
|
&& case "$arch" in aarch64) tilt_arch=arm64 ;; x86_64) tilt_arch=x86_64 ;; *) echo "unsupported arch: $arch" >&2; exit 1 ;; esac \
|
|
19
20
|
&& curl -fsSL "https://github.com/tilt-dev/tilt/releases/download/v${TILT_VERSION}/tilt.${TILT_VERSION}.linux.${tilt_arch}.tar.gz" \
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
COMPOSE_START_TIMEOUT_MS,
|
|
4
|
+
TILT_TRIGGER_TIMEOUT_MS,
|
|
5
|
+
decideWakeAction,
|
|
6
|
+
evaluateReadiness,
|
|
7
|
+
wakeTimeoutMs,
|
|
8
|
+
} from "./decide";
|
|
3
9
|
|
|
4
10
|
describe("decideWakeAction", () => {
|
|
5
11
|
test("never-created resource: tilt trigger only, no Sablier start", () => {
|
|
@@ -56,6 +62,31 @@ describe("decideWakeAction", () => {
|
|
|
56
62
|
]);
|
|
57
63
|
});
|
|
58
64
|
|
|
65
|
+
test("never-created resource with compose details: started directly, not queued in Tilt", () => {
|
|
66
|
+
const action = decideWakeAction({
|
|
67
|
+
resourceContainerName: "data-governance-api",
|
|
68
|
+
resourceState: "absent",
|
|
69
|
+
dependencies: [{ name: "peer-api", state: "absent" }],
|
|
70
|
+
hasComposeInvocation: true,
|
|
71
|
+
});
|
|
72
|
+
expect(action.useComposeStart).toBe(true);
|
|
73
|
+
// Dependencies carry no compose details, so they still go through Tilt.
|
|
74
|
+
expect(action.tiltTriggerNames).toEqual(["peer-api"]);
|
|
75
|
+
expect(action.useSablierStart).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("existing (exited) container keeps the Sablier path even with compose details", () => {
|
|
79
|
+
const action = decideWakeAction({
|
|
80
|
+
resourceContainerName: "data-governance-api",
|
|
81
|
+
resourceState: "exited",
|
|
82
|
+
dependencies: [],
|
|
83
|
+
hasComposeInvocation: true,
|
|
84
|
+
});
|
|
85
|
+
expect(action.useComposeStart).toBe(false);
|
|
86
|
+
expect(action.tiltTriggerNames).toEqual(["data-governance-api"]);
|
|
87
|
+
expect(action.useSablierStart).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
59
90
|
test("running dependency is not re-triggered", () => {
|
|
60
91
|
const action = decideWakeAction({
|
|
61
92
|
resourceContainerName: "data-governance-api",
|
|
@@ -66,6 +97,21 @@ describe("decideWakeAction", () => {
|
|
|
66
97
|
});
|
|
67
98
|
});
|
|
68
99
|
|
|
100
|
+
describe("wakeTimeoutMs", () => {
|
|
101
|
+
test("a direct compose start only waits for boot + healthcheck", () => {
|
|
102
|
+
expect(wakeTimeoutMs(true)).toBe(COMPOSE_START_TIMEOUT_MS);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the tilt trigger path allows for an image build, under Bun's 255s idle cap", () => {
|
|
106
|
+
expect(wakeTimeoutMs(false)).toBe(TILT_TRIGGER_TIMEOUT_MS);
|
|
107
|
+
expect(TILT_TRIGGER_TIMEOUT_MS).toBeLessThan(255_000);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an explicit X-Wake-Timeout-Ms wins", () => {
|
|
111
|
+
expect(wakeTimeoutMs(false, 5_000)).toBe(5_000);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
69
115
|
describe("evaluateReadiness", () => {
|
|
70
116
|
test("ready once every required name reports healthy", () => {
|
|
71
117
|
const outcome = evaluateReadiness(["a", "b"], { a: true, b: true });
|
|
@@ -20,11 +20,26 @@ export interface WakeRequestSnapshot {
|
|
|
20
20
|
resourceContainerName: string;
|
|
21
21
|
resourceState: ContainerState;
|
|
22
22
|
dependencies: DependencySnapshot[];
|
|
23
|
+
/**
|
|
24
|
+
* Whether the route carried enough compose detail (project, files, image)
|
|
25
|
+
* for the gateway to create the resource's container itself.
|
|
26
|
+
*/
|
|
27
|
+
hasComposeInvocation?: boolean;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export interface WakeAction {
|
|
26
31
|
/** Resources to `tilt trigger` (task 1.5: Tilt never cascades this on its own). */
|
|
27
32
|
tiltTriggerNames: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Whether to create/start the resource's container directly with
|
|
35
|
+
* `docker compose up --no-build` instead of `tilt trigger`. `tilt trigger`
|
|
36
|
+
* only queues the resource: with Tilt's build slots busy (e.g. 100+
|
|
37
|
+
* resources rebuilding after a re-vendor), a triggered resource waited
|
|
38
|
+
* behind them and the request timed out (observed live against
|
|
39
|
+
* tdk-erp-system, see tasks.md 7.2). server.ts falls back to `tilt trigger`
|
|
40
|
+
* when no image exists yet to start from.
|
|
41
|
+
*/
|
|
42
|
+
useComposeStart: boolean;
|
|
28
43
|
/**
|
|
29
44
|
* Whether to also try Sablier's own Docker-level start for the resource.
|
|
30
45
|
* Only meaningful when the resource's container already exists (D2/D3):
|
|
@@ -39,8 +54,9 @@ export interface WakeAction {
|
|
|
39
54
|
*/
|
|
40
55
|
export function decideWakeAction(snapshot: WakeRequestSnapshot): WakeAction {
|
|
41
56
|
const tiltTriggerNames: string[] = [];
|
|
57
|
+
const useComposeStart = snapshot.resourceState === "absent" && snapshot.hasComposeInvocation === true;
|
|
42
58
|
|
|
43
|
-
if (snapshot.resourceState !== "running") {
|
|
59
|
+
if (snapshot.resourceState !== "running" && !useComposeStart) {
|
|
44
60
|
tiltTriggerNames.push(snapshot.resourceContainerName);
|
|
45
61
|
}
|
|
46
62
|
for (const dep of snapshot.dependencies) {
|
|
@@ -51,7 +67,21 @@ export function decideWakeAction(snapshot: WakeRequestSnapshot): WakeAction {
|
|
|
51
67
|
|
|
52
68
|
const useSablierStart = snapshot.resourceState === "exited" || snapshot.resourceState === "created";
|
|
53
69
|
|
|
54
|
-
return { tiltTriggerNames, useSablierStart };
|
|
70
|
+
return { tiltTriggerNames, useComposeStart, useSablierStart };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Wait budget when the gateway starts an existing image itself (boot + healthcheck only). */
|
|
74
|
+
export const COMPOSE_START_TIMEOUT_MS = 180_000;
|
|
75
|
+
/**
|
|
76
|
+
* Wait budget when the resource has to go through `tilt trigger` (image build
|
|
77
|
+
* included). Kept under Bun.serve's 255s idleTimeout cap in server.ts.
|
|
78
|
+
*/
|
|
79
|
+
export const TILT_TRIGGER_TIMEOUT_MS = 240_000;
|
|
80
|
+
|
|
81
|
+
/** The wait budget for a wake: an explicit X-Wake-Timeout-Ms wins, else by start path. */
|
|
82
|
+
export function wakeTimeoutMs(startedViaCompose: boolean, overrideMs?: number): number {
|
|
83
|
+
if (overrideMs && overrideMs > 0) return overrideMs;
|
|
84
|
+
return startedViaCompose ? COMPOSE_START_TIMEOUT_MS : TILT_TRIGGER_TIMEOUT_MS;
|
|
55
85
|
}
|
|
56
86
|
|
|
57
87
|
export interface WaitOutcome {
|
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
* injects (set by the file-provider entry generated in
|
|
11
11
|
* traefik_static_routes.star) rather than the path, so the original request
|
|
12
12
|
* path/query reach the real backend unchanged once it's ready:
|
|
13
|
-
* X-Wake-Resource Tilt/Compose resource name (for `tilt trigger
|
|
14
|
-
*
|
|
15
|
-
* resolves a service by this name too).
|
|
13
|
+
* X-Wake-Resource Tilt/Compose resource name (for `tilt trigger` and
|
|
14
|
+
* `docker compose up <service>`).
|
|
16
15
|
* X-Wake-Container The actual Docker container name (for `docker
|
|
17
|
-
* inspect` state/health -- NOT the same string as
|
|
16
|
+
* inspect` state/health and the proxy target -- NOT the same string as
|
|
18
17
|
* the resource name; Compose derives it as
|
|
19
18
|
* <project>-<service>-<replica>, confirmed empirically
|
|
20
19
|
* against a live TDK container, e.g.
|
|
@@ -23,15 +22,28 @@
|
|
|
23
22
|
* X-Wake-Deps Comma-separated "resourceName=containerName" pairs
|
|
24
23
|
* for required dependencies (task 1.5: Tilt never
|
|
25
24
|
* cascades a trigger to these on its own).
|
|
25
|
+
* X-Wake-Compose-Project The resource's Compose project (e.g. "analytics-autogenerated").
|
|
26
|
+
* X-Wake-Compose-Files Comma-separated absolute compose file paths, the
|
|
27
|
+
* same ones Tilt's `<resource>-run-only` resource
|
|
28
|
+
* passes. The project root is mounted into this
|
|
29
|
+
* container at the same absolute path, so relative
|
|
30
|
+
* paths inside those files resolve exactly as for Tilt.
|
|
31
|
+
* X-Wake-Env-File Absolute path of the project's .env (optional).
|
|
32
|
+
* X-Wake-Image The image the compose service declares
|
|
33
|
+
* (<service>_<resource>:dev).
|
|
26
34
|
* X-Wake-Timeout-Ms, X-Wake-Proxy-Port, X-Wake-Session-Duration
|
|
27
35
|
*/
|
|
28
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
decideWakeAction,
|
|
38
|
+
evaluateReadiness,
|
|
39
|
+
wakeTimeoutMs,
|
|
40
|
+
type ContainerState,
|
|
41
|
+
} from "./decide";
|
|
29
42
|
|
|
30
43
|
const TILT_HOST = process.env.TILT_HOST || "host.docker.internal";
|
|
31
44
|
const TILT_PORT = process.env.TILT_PORT || "10350";
|
|
32
45
|
const SABLIER_URL = process.env.SABLIER_URL || "http://sablier:10000";
|
|
33
46
|
const POLL_INTERVAL_MS = 1000;
|
|
34
|
-
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
35
47
|
|
|
36
48
|
async function run(cmd: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
37
49
|
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
@@ -66,7 +78,52 @@ async function isHealthy(name: string): Promise<boolean> {
|
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
async function tiltTrigger(name: string): Promise<void> {
|
|
69
|
-
await run(["tilt", "trigger", name, "--host", TILT_HOST, "--port", TILT_PORT]);
|
|
81
|
+
const { code, stderr } = await run(["tilt", "trigger", name, "--host", TILT_HOST, "--port", TILT_PORT]);
|
|
82
|
+
console.log(`tilt trigger ${name}: ${code === 0 ? "queued" : `failed (${stderr.trim()})`}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ComposeInvocation {
|
|
86
|
+
project: string;
|
|
87
|
+
files: string[];
|
|
88
|
+
envFile: string;
|
|
89
|
+
image: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Make sure the compose service's declared image exists locally. Tilt tags its
|
|
94
|
+
* own builds `<repo>:tilt-<hash>` and injects that tag when it runs compose, so
|
|
95
|
+
* the declared `<repo>:dev` tag may be missing even though the resource was
|
|
96
|
+
* built; retag Tilt's newest build in that case. False when nothing was ever
|
|
97
|
+
* built -- only `tilt trigger` (which builds) can wake the resource then.
|
|
98
|
+
*/
|
|
99
|
+
async function ensureImage(image: string): Promise<boolean> {
|
|
100
|
+
if ((await run(["docker", "image", "inspect", "--format", "{{.Id}}", image])).code === 0) return true;
|
|
101
|
+
const repo = image.split(":")[0];
|
|
102
|
+
// `docker images` lists newest first.
|
|
103
|
+
const { code, stdout } = await run(["docker", "images", repo, "--format", "{{.Tag}}"]);
|
|
104
|
+
if (code !== 0) return false;
|
|
105
|
+
const tiltTag = stdout.split("\n").map((t) => t.trim()).find((t) => t.startsWith("tilt-"));
|
|
106
|
+
if (!tiltTag) return false;
|
|
107
|
+
return (await run(["docker", "tag", `${repo}:${tiltTag}`, image])).code === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create and start the resource's container directly, with the same
|
|
112
|
+
* `docker compose ... up -d --no-build` its Tilt `-run-only` resource runs,
|
|
113
|
+
* bypassing Tilt's build queue (see decide.ts useComposeStart).
|
|
114
|
+
*/
|
|
115
|
+
async function composeStart(compose: ComposeInvocation, service: string): Promise<boolean> {
|
|
116
|
+
if (!(await ensureImage(compose.image))) {
|
|
117
|
+
console.log(`compose start ${service}: no local image for ${compose.image}, falling back to tilt trigger`);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const args = ["docker", "compose", "-p", compose.project];
|
|
121
|
+
for (const file of compose.files) args.push("-f", file);
|
|
122
|
+
if (compose.envFile) args.push("--env-file", compose.envFile);
|
|
123
|
+
args.push("up", "-d", "--no-build", "--no-deps", service);
|
|
124
|
+
const { code, stderr } = await run(args);
|
|
125
|
+
console.log(`compose start ${service}: ${code === 0 ? "started" : `failed (${stderr.trim()})`}`);
|
|
126
|
+
return code === 0;
|
|
70
127
|
}
|
|
71
128
|
|
|
72
129
|
/**
|
|
@@ -110,14 +167,23 @@ function parseRequest(req: Request) {
|
|
|
110
167
|
const resource = req.headers.get("X-Wake-Resource") || "";
|
|
111
168
|
const container = req.headers.get("X-Wake-Container") || resource;
|
|
112
169
|
const deps = parseDeps(req.headers.get("X-Wake-Deps") || "");
|
|
113
|
-
const
|
|
170
|
+
const timeoutOverrideMs = Number(req.headers.get("X-Wake-Timeout-Ms")) || undefined;
|
|
114
171
|
const proxyPort = req.headers.get("X-Wake-Proxy-Port") || "80";
|
|
115
172
|
const sessionDuration = req.headers.get("X-Wake-Session-Duration") || "10m";
|
|
116
|
-
|
|
173
|
+
const composeFiles = (req.headers.get("X-Wake-Compose-Files") || "").split(",").filter(Boolean);
|
|
174
|
+
const compose: ComposeInvocation = {
|
|
175
|
+
project: req.headers.get("X-Wake-Compose-Project") || "",
|
|
176
|
+
files: composeFiles,
|
|
177
|
+
envFile: req.headers.get("X-Wake-Env-File") || "",
|
|
178
|
+
image: req.headers.get("X-Wake-Image") || "",
|
|
179
|
+
};
|
|
180
|
+
const hasCompose = Boolean(compose.project && compose.files.length && compose.image);
|
|
181
|
+
return { resource, container, deps, timeoutOverrideMs, proxyPort, sessionDuration, compose, hasCompose };
|
|
117
182
|
}
|
|
118
183
|
|
|
119
184
|
async function handleWake(req: Request): Promise<Response> {
|
|
120
|
-
const { resource, container, deps,
|
|
185
|
+
const { resource, container, deps, timeoutOverrideMs, proxyPort, sessionDuration, compose, hasCompose } =
|
|
186
|
+
parseRequest(req);
|
|
121
187
|
if (!resource) {
|
|
122
188
|
return new Response("wake gateway: missing X-Wake-Resource header (set by the generated Traefik static route)", { status: 400 });
|
|
123
189
|
}
|
|
@@ -127,14 +193,32 @@ async function handleWake(req: Request): Promise<Response> {
|
|
|
127
193
|
deps.map(async (dep) => ({ name: dep.resourceName, state: await containerState(dep.containerName) })),
|
|
128
194
|
);
|
|
129
195
|
|
|
130
|
-
const action = decideWakeAction({
|
|
196
|
+
const action = decideWakeAction({
|
|
197
|
+
resourceContainerName: resource,
|
|
198
|
+
resourceState,
|
|
199
|
+
dependencies,
|
|
200
|
+
hasComposeInvocation: hasCompose,
|
|
201
|
+
});
|
|
202
|
+
console.log(
|
|
203
|
+
`wake ${resource}: state=${resourceState} compose=${action.useComposeStart} ` +
|
|
204
|
+
`trigger=[${action.tiltTriggerNames.join(",")}] sablier=${action.useSablierStart}`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Dependencies (and the resource itself when it can't be started directly)
|
|
208
|
+
// go through tilt trigger (task 1.5: Tilt never cascades this on its own).
|
|
209
|
+
const tiltNames = [...action.tiltTriggerNames];
|
|
210
|
+
let startedViaCompose = false;
|
|
211
|
+
if (action.useComposeStart) {
|
|
212
|
+
startedViaCompose = await composeStart(compose, resource);
|
|
213
|
+
if (!startedViaCompose) tiltNames.unshift(resource);
|
|
214
|
+
}
|
|
215
|
+
const timeoutMs = wakeTimeoutMs(startedViaCompose, timeoutOverrideMs);
|
|
131
216
|
const deadline = Date.now() + timeoutMs;
|
|
132
217
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// waits for the resource itself in one round trip.
|
|
218
|
+
// Sablier's blocking call, when applicable, both starts AND waits for the
|
|
219
|
+
// resource itself in one round trip.
|
|
136
220
|
const [, resourceReadyViaSablier] = await Promise.all([
|
|
137
|
-
Promise.all(
|
|
221
|
+
Promise.all(tiltNames.map(tiltTrigger)),
|
|
138
222
|
action.useSablierStart
|
|
139
223
|
? sablierBlockingWait(resource, sessionDuration, timeoutMs)
|
|
140
224
|
: Promise.resolve(false),
|
|
@@ -154,7 +238,7 @@ async function handleWake(req: Request): Promise<Response> {
|
|
|
154
238
|
}
|
|
155
239
|
const outcome = evaluateReadiness(requiredNames, healthy);
|
|
156
240
|
if (outcome.ready) {
|
|
157
|
-
return proxyThrough(req,
|
|
241
|
+
return proxyThrough(req, container, proxyPort);
|
|
158
242
|
}
|
|
159
243
|
await Bun.sleep(POLL_INTERVAL_MS);
|
|
160
244
|
}
|
|
@@ -176,9 +260,16 @@ const STRIPPED_REQUEST_HEADERS = new Set([
|
|
|
176
260
|
"x-wake-timeout-ms",
|
|
177
261
|
"x-wake-proxy-port",
|
|
178
262
|
"x-wake-session-duration",
|
|
263
|
+
"x-wake-compose-project",
|
|
264
|
+
"x-wake-compose-files",
|
|
265
|
+
"x-wake-env-file",
|
|
266
|
+
"x-wake-image",
|
|
179
267
|
]);
|
|
180
268
|
|
|
181
|
-
|
|
269
|
+
// Proxies to the container name rather than the Compose service name: every
|
|
270
|
+
// TDK project's resources share the traefik-public network, where a bare
|
|
271
|
+
// service name is only an alias and could collide across projects.
|
|
272
|
+
async function proxyThrough(req: Request, container: string, port: string): Promise<Response> {
|
|
182
273
|
// The original path/query reach the gateway untouched (config travels via
|
|
183
274
|
// X-Wake-* headers, not the path), so they're forwarded as-is.
|
|
184
275
|
const target = new URL(req.url);
|
|
@@ -186,7 +277,7 @@ async function proxyThrough(req: Request, resource: string, port: string): Promi
|
|
|
186
277
|
// Set hostname/port separately, not via `.host` -- confirmed empirically
|
|
187
278
|
// that `target.host = "name:port"` silently keeps the URL's original port
|
|
188
279
|
// when one is already present instead of replacing it.
|
|
189
|
-
target.hostname =
|
|
280
|
+
target.hostname = container;
|
|
190
281
|
target.port = port;
|
|
191
282
|
|
|
192
283
|
const headers = new Headers();
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
load("../constants.star", "PlatformDockerConstants")
|
|
9
9
|
load("../config/healthcheck.star", "compose_healthcheck_timing")
|
|
10
|
+
load("../networking/traefik_static_routes.star", "normalize_abs_path")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def generate_standalone_traefik_compose():
|
|
@@ -91,7 +92,7 @@ services:
|
|
|
91
92
|
- PORT={gateway_port}
|
|
92
93
|
volumes:
|
|
93
94
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
94
|
-
- {tilt_dev_dir}:/root/.tilt-dev:ro
|
|
95
|
+
- {tilt_dev_dir}:/root/.tilt-dev:ro{project_root_mount}
|
|
95
96
|
networks:
|
|
96
97
|
- traefik-public
|
|
97
98
|
|
|
@@ -108,4 +109,25 @@ networks:
|
|
|
108
109
|
gateway_container=PlatformDockerConstants.TRAEFIK_WAKE_GATEWAY_CONTAINER,
|
|
109
110
|
gateway_port=PlatformDockerConstants.TRAEFIK_WAKE_GATEWAY_PORT,
|
|
110
111
|
tilt_dev_dir=os.environ.get("HOME", "") + "/.tilt-dev",
|
|
112
|
+
project_root_mount=_project_root_mount(),
|
|
111
113
|
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _project_root_mount():
|
|
117
|
+
"""Read-only mount of the project at its own absolute host path.
|
|
118
|
+
|
|
119
|
+
The gateway runs `docker compose up --no-build` for a deferred resource
|
|
120
|
+
itself (bypassing Tilt's build queue), using the compose files Tilt uses.
|
|
121
|
+
Compose resolves relative paths in them (env_file etc.) client-side, so
|
|
122
|
+
they must exist at the same absolute paths inside the gateway container.
|
|
123
|
+
Empty when TDK_PROJECT_ROOT is unset (the generated Tiltfile sets it).
|
|
124
|
+
"""
|
|
125
|
+
root = project_root_abspath()
|
|
126
|
+
if not root:
|
|
127
|
+
return ""
|
|
128
|
+
return "\n - " + root + ":" + root + ":ro"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def project_root_abspath():
|
|
132
|
+
"""Absolute, normalized project root (TDK_PROJECT_ROOT), or "" when unset."""
|
|
133
|
+
return normalize_abs_path(os.environ.get("TDK_PROJECT_ROOT", ""))
|
|
@@ -13,10 +13,15 @@
|
|
|
13
13
|
# backend was healthy).
|
|
14
14
|
#
|
|
15
15
|
# Config for a request travels to the gateway via headers (X-Wake-*), not
|
|
16
|
-
# the path/query
|
|
17
|
-
#
|
|
16
|
+
# the path/query. The route still strips the same `apiPath` prefix the real
|
|
17
|
+
# Docker-label `-project` router strips (get_backend_traefik_labels) before
|
|
18
|
+
# forwarding -- confirmed live against tdk-erp-system (task 7.2) that a
|
|
19
|
+
# backend's app only serves its bare health path (e.g. `/health`), not the
|
|
20
|
+
# full `/api/{name}/health` the public route is matched on; without this, the
|
|
21
|
+
# gateway's own proxy-through would forward the unstripped path once healthy
|
|
22
|
+
# and the app would 404 on it, even though the wake itself succeeded.
|
|
18
23
|
|
|
19
|
-
load("./traefik_helpers.star", "project_backend_rule", "build_entrypoints")
|
|
24
|
+
load("./traefik_helpers.star", "project_backend_rule", "cli_api_path", "build_entrypoints")
|
|
20
25
|
load("./traefik_constants.star",
|
|
21
26
|
"TRAEFIK_BACKEND_ENABLE_HTTP",
|
|
22
27
|
"TRAEFIK_BACKEND_ENABLE_HTTPS",
|
|
@@ -25,6 +30,29 @@ load("./traefik_constants.star",
|
|
|
25
30
|
STATIC_ROUTE_PRIORITY = 1
|
|
26
31
|
|
|
27
32
|
|
|
33
|
+
def normalize_abs_path(path):
|
|
34
|
+
"""Absolute path with `.`/`..` segments collapsed, or "" for "".
|
|
35
|
+
|
|
36
|
+
Tilt's os.path.abspath does not collapse `..` (confirmed:
|
|
37
|
+
abspath("/a/.tdk/.tdk-out/../..") comes back unchanged), and the
|
|
38
|
+
generated Tiltfile's project root is exactly that shape
|
|
39
|
+
(config.main_dir + "/../.."). The gateway's project mount and the compose
|
|
40
|
+
paths in its route headers must match byte-for-byte, so both go through here.
|
|
41
|
+
"""
|
|
42
|
+
if not path:
|
|
43
|
+
return ""
|
|
44
|
+
parts = []
|
|
45
|
+
for segment in os.path.abspath(path).split("/"):
|
|
46
|
+
if segment == "" or segment == ".":
|
|
47
|
+
continue
|
|
48
|
+
if segment == "..":
|
|
49
|
+
if parts:
|
|
50
|
+
parts.pop()
|
|
51
|
+
continue
|
|
52
|
+
parts.append(segment)
|
|
53
|
+
return "/" + "/".join(parts)
|
|
54
|
+
|
|
55
|
+
|
|
28
56
|
def _container_name(compose_project_name, res_name):
|
|
29
57
|
"""Docker Compose's own naming convention: <project>-<service>-<replica>.
|
|
30
58
|
|
|
@@ -43,6 +71,9 @@ def generate_static_wake_route(
|
|
|
43
71
|
gateway_host,
|
|
44
72
|
gateway_port,
|
|
45
73
|
dependencies=None,
|
|
74
|
+
compose_files=None,
|
|
75
|
+
env_file="",
|
|
76
|
+
image="",
|
|
46
77
|
):
|
|
47
78
|
"""YAML for one resource's Traefik file-provider dynamic-config entry.
|
|
48
79
|
|
|
@@ -51,21 +82,36 @@ def generate_static_wake_route(
|
|
|
51
82
|
different compose project than the resource itself, so the project name
|
|
52
83
|
travels alongside the resource name rather than being assumed shared.
|
|
53
84
|
|
|
85
|
+
`compose_files` (absolute paths), `env_file` and `image` let the gateway
|
|
86
|
+
create the container itself with `docker compose up --no-build` when the
|
|
87
|
+
image already exists, instead of waiting in Tilt's build queue behind
|
|
88
|
+
`tilt trigger`. Omitted headers make the gateway use `tilt trigger` only.
|
|
89
|
+
|
|
54
90
|
Written to the directory PlatformDockerConstants.TRAEFIK_DYNAMIC_DIR_REL
|
|
55
91
|
mounts into the standalone Traefik container (traefik_standalone.star).
|
|
56
92
|
"""
|
|
57
93
|
dependencies = dependencies or []
|
|
58
94
|
|
|
59
95
|
rule = project_backend_rule(manifest, resource_entry_name)
|
|
96
|
+
api_path = cli_api_path(resource_entry_name, manifest)
|
|
60
97
|
entrypoints = build_entrypoints(TRAEFIK_BACKEND_ENABLE_HTTP, TRAEFIK_BACKEND_ENABLE_HTTPS)
|
|
61
98
|
router_name = resource_entry_name + "-wake"
|
|
62
|
-
|
|
99
|
+
headers_middleware_name = resource_entry_name + "-wake-headers"
|
|
100
|
+
strip_middleware_name = resource_entry_name + "-wake-strip"
|
|
63
101
|
service_name = resource_entry_name + "-wake-gateway"
|
|
64
102
|
container_name = _container_name(compose_project_name, resource_entry_name)
|
|
65
103
|
deps_header = ",".join([
|
|
66
104
|
dep_res_name + "=" + _container_name(dep_project_name, dep_res_name)
|
|
67
105
|
for dep_res_name, dep_project_name in dependencies
|
|
68
106
|
])
|
|
107
|
+
compose_headers = ""
|
|
108
|
+
if compose_files and image:
|
|
109
|
+
compose_headers = (
|
|
110
|
+
'\n X-Wake-Compose-Project: "' + compose_project_name + '"'
|
|
111
|
+
+ '\n X-Wake-Compose-Files: "' + ",".join(compose_files) + '"'
|
|
112
|
+
+ '\n X-Wake-Env-File: "' + env_file + '"'
|
|
113
|
+
+ '\n X-Wake-Image: "' + image + '"'
|
|
114
|
+
)
|
|
69
115
|
|
|
70
116
|
return """# SYSTEM-GENERATED - DO NOT EDIT (sablier.deferStart static wake route)
|
|
71
117
|
http:
|
|
@@ -76,16 +122,21 @@ http:
|
|
|
76
122
|
- {entrypoint}
|
|
77
123
|
service: {service_name}
|
|
78
124
|
middlewares:
|
|
79
|
-
- {
|
|
125
|
+
- {strip_middleware_name}
|
|
126
|
+
- {headers_middleware_name}
|
|
80
127
|
priority: {priority}
|
|
81
128
|
middlewares:
|
|
82
|
-
{
|
|
129
|
+
{strip_middleware_name}:
|
|
130
|
+
stripPrefix:
|
|
131
|
+
prefixes:
|
|
132
|
+
- "{api_path}"
|
|
133
|
+
{headers_middleware_name}:
|
|
83
134
|
headers:
|
|
84
135
|
customRequestHeaders:
|
|
85
136
|
X-Wake-Resource: "{resource_entry_name}"
|
|
86
137
|
X-Wake-Container: "{container_name}"
|
|
87
138
|
X-Wake-Deps: "{deps_header}"
|
|
88
|
-
X-Wake-Proxy-Port: "{internal_port}"
|
|
139
|
+
X-Wake-Proxy-Port: "{internal_port}"{compose_headers}
|
|
89
140
|
services:
|
|
90
141
|
{service_name}:
|
|
91
142
|
loadBalancer:
|
|
@@ -96,12 +147,15 @@ http:
|
|
|
96
147
|
rule=rule,
|
|
97
148
|
entrypoint=entrypoints.split(",")[0],
|
|
98
149
|
service_name=service_name,
|
|
99
|
-
|
|
150
|
+
strip_middleware_name=strip_middleware_name,
|
|
151
|
+
headers_middleware_name=headers_middleware_name,
|
|
152
|
+
api_path=api_path,
|
|
100
153
|
priority=STATIC_ROUTE_PRIORITY,
|
|
101
154
|
resource_entry_name=resource_entry_name,
|
|
102
155
|
container_name=container_name,
|
|
103
156
|
deps_header=deps_header,
|
|
104
157
|
internal_port=internal_port,
|
|
158
|
+
compose_headers=compose_headers,
|
|
105
159
|
gateway_host=gateway_host,
|
|
106
160
|
gateway_port=gateway_port,
|
|
107
161
|
)
|
package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star
CHANGED
|
@@ -10,7 +10,7 @@ OUTPUT = struct(AUTOGENERATED_FOLDER = ".autogenerated")
|
|
|
10
10
|
load('../../../platform/docker/index.star', 'Docker')
|
|
11
11
|
load('../../../platform/docker/constants.star', 'PlatformDockerConstants')
|
|
12
12
|
load('../../../tilt/manifest/constants.star', 'BASE_PORT_FRONTEND', 'BASE_PORT_BACKEND')
|
|
13
|
-
load('../../../platform/docker/networking/traefik_static_routes.star', 'generate_static_wake_route')
|
|
13
|
+
load('../../../platform/docker/networking/traefik_static_routes.star', 'generate_static_wake_route', 'normalize_abs_path')
|
|
14
14
|
load('../../../platform/docker/networking/sablier_container_cycle.star', 'sablier_middleware_suffix')
|
|
15
15
|
|
|
16
16
|
load('./builders/typescript.star', 'TypescriptBuilders')
|
|
@@ -213,9 +213,38 @@ def _build_live_update_rules(res_path, full_res_path, syncs):
|
|
|
213
213
|
return live_update_rules
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
def _app_image_name(resource_name, res_name):
|
|
217
|
+
"""The image ref docker_build builds and the app compose service declares."""
|
|
218
|
+
return resource_name + '_' + res_name + ':dev'
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _wake_compose_invocation(project_root, resource_path, res_name, resource_name):
|
|
222
|
+
"""Compose files, env file and image for the wake gateway's direct start.
|
|
223
|
+
|
|
224
|
+
The same files/--env-file the `-run-only` resource passes, as absolute
|
|
225
|
+
normalized paths: the gateway mounts the project at the same normalized
|
|
226
|
+
root (traefik_standalone.star), so these resolve identically there.
|
|
227
|
+
Returns None without a project root (paths would be relative to Tilt's cwd).
|
|
228
|
+
"""
|
|
229
|
+
if not project_root:
|
|
230
|
+
return None
|
|
231
|
+
root = normalize_abs_path(project_root)
|
|
232
|
+
compose_dir = root + '/' + resource_path + '/' + OUTPUT.AUTOGENERATED_FOLDER
|
|
233
|
+
files = [compose_dir + '/docker-compose.app.autogenerated.yml']
|
|
234
|
+
port_override = compose_dir + '/docker-compose.port-override.yml'
|
|
235
|
+
if os.path.exists(port_override):
|
|
236
|
+
files.append(port_override)
|
|
237
|
+
env_file = root + '/.env'
|
|
238
|
+
return {
|
|
239
|
+
'compose_files': files,
|
|
240
|
+
'env_file': env_file if os.path.exists(env_file) else '',
|
|
241
|
+
'image': _app_image_name(resource_name, res_name),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
216
245
|
def _register_docker_build(config, live_update_rules):
|
|
217
246
|
"""Register docker_build with live_update for hot-reload support."""
|
|
218
|
-
image_name = config['resource_name']
|
|
247
|
+
image_name = _app_image_name(config['resource_name'], config['res_name'])
|
|
219
248
|
project_root = config.get('project_root', '')
|
|
220
249
|
# Use custom dockerfile from config if specified, otherwise use autogenerated default
|
|
221
250
|
# The res_path is relative to project_root (e.g., 'services/product/...')
|
|
@@ -478,6 +507,9 @@ def register_compose_resources(resource_config, ctx, runtime_flags, manifest_sta
|
|
|
478
507
|
if resource_defers_start(manifest) and write_file:
|
|
479
508
|
internal_port = res.get('port', manifest.get('port', BASE_PORT_BACKEND))
|
|
480
509
|
static_route_path = PlatformDockerConstants.TRAEFIK_DYNAMIC_DIR_REL + '/' + res['name'] + '.yml'
|
|
510
|
+
compose_invocation = _wake_compose_invocation(
|
|
511
|
+
ctx.get('project_root', ''), resource_path, res['name'], resource_name,
|
|
512
|
+
) or {}
|
|
481
513
|
write_file(static_route_path, generate_static_wake_route(
|
|
482
514
|
resource_entry_name=res['name'],
|
|
483
515
|
compose_project_name=compose_project_name,
|
|
@@ -486,6 +518,9 @@ def register_compose_resources(resource_config, ctx, runtime_flags, manifest_sta
|
|
|
486
518
|
gateway_host=PlatformDockerConstants.TRAEFIK_WAKE_GATEWAY_CONTAINER,
|
|
487
519
|
gateway_port=PlatformDockerConstants.TRAEFIK_WAKE_GATEWAY_PORT,
|
|
488
520
|
dependencies=resolve_defer_start_dependencies(manifest, resource_manifests, all_services_map),
|
|
521
|
+
compose_files=compose_invocation.get('compose_files'),
|
|
522
|
+
env_file=compose_invocation.get('env_file', ''),
|
|
523
|
+
image=compose_invocation.get('image', ''),
|
|
489
524
|
))
|
|
490
525
|
|
|
491
526
|
resource_entries.append(entry)
|