@podosoft/podokit 0.16.3 → 0.17.0
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/dist/deploy-compose-exec.d.ts +93 -0
- package/dist/deploy-compose-exec.js +198 -0
- package/dist/deploy-compose-profile.d.ts +107 -0
- package/dist/deploy-compose-profile.js +361 -0
- package/dist/deploy-compose-render.d.ts +48 -0
- package/dist/deploy-compose-render.js +333 -0
- package/dist/deploy-compose-sync.d.ts +144 -0
- package/dist/deploy-compose-sync.js +412 -0
- package/dist/deploy-compose.d.ts +98 -0
- package/dist/deploy-compose.js +667 -0
- package/dist/deploy-dispatch.d.ts +33 -0
- package/dist/deploy-dispatch.js +58 -0
- package/dist/deploy-driver.d.ts +25 -0
- package/dist/deploy-driver.js +78 -0
- package/dist/deploy-profile.d.ts +4 -7
- package/dist/deploy-profile.js +86 -274
- package/dist/deploy-render.js +10 -1
- package/dist/deploy-schema.d.ts +56 -0
- package/dist/deploy-schema.js +261 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +178 -6
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +30 -1
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
- package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +24 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/[...path]/+server.ts +6 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/auth/[...all]/+server.ts +6 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
- package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
- package/dist/templates/todo/AGENTS.md +2 -1
- package/dist/templates/todo/Dockerfile.dev +34 -3
- package/dist/templates/todo/apps/api/Dockerfile +68 -7
- package/dist/templates/todo/apps/web/Dockerfile +60 -8
- package/dist/templates/todo/apps/web/package.json +2 -1
- package/dist/templates/todo/apps/web/server.js +31 -0
- package/dist/templates/todo/apps/web/src/lib/server/api.ts +2 -2
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +24 -1
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/todo/apps/web/src/routes/api/[...path]/+server.ts +6 -2
- package/dist/templates/todo/apps/web/src/routes/api/auth/[...all]/+server.ts +6 -2
- package/dist/templates/todo/apps/web/vite.config.ts +17 -7
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/todo/dot-dockerignore +7 -0
- package/dist/templates/todo/dot-env.example +7 -0
- package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
- package/dist/templates/todo/infra/k3s/README.md +18 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket upgrades, proxied by the web server itself.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: a SvelteKit server route cannot serve a WebSocket upgrade.
|
|
5
|
+
* `+server.ts` handlers answer HTTP requests and never see an `upgrade` event, and
|
|
6
|
+
* the `/api/*` proxy is HTTP-only for the same reason — `fetch` cannot carry a 101.
|
|
7
|
+
* Meanwhile a deployment routes all public traffic to the web app, so an API
|
|
8
|
+
* WebSocket gateway has no path to reach at all. Development hides this, because
|
|
9
|
+
* Vite proxies upgrades; the feature then dies silently once deployed.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ NOTHING IS FORWARDED BY DEFAULT. `WS_PROXY_PATHS` names the exact paths that may
|
|
12
|
+
* upgrade, comma-separated, and anything else is destroyed rather than proxied. A
|
|
13
|
+
* relay that forwards any path is an open tunnel to every private route on the API,
|
|
14
|
+
* reachable from the public origin without a session. Adding a path is a security
|
|
15
|
+
* decision, not a routing tweak — the API must authorise that socket at handshake
|
|
16
|
+
* time, from the session cookie or its own credential.
|
|
17
|
+
*
|
|
18
|
+
* This file is plain JavaScript on purpose: `server.js` imports `./build/`, which
|
|
19
|
+
* only exists after a build, so the entry point cannot be type-checked. Everything
|
|
20
|
+
* with a decision in it lives here, where it is unit-tested.
|
|
21
|
+
*/
|
|
22
|
+
import http from "node:http";
|
|
23
|
+
import https from "node:https";
|
|
24
|
+
|
|
25
|
+
/** @typedef {Record<string, string | string[] | undefined>} Headers */
|
|
26
|
+
/** @typedef {{ kind: "proxy", path: string } | { kind: "reject", reason: string }} UpgradeDecision */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The upgrade paths this deployment allows, matched whole — never as a prefix, a
|
|
30
|
+
* wildcard, or a pattern.
|
|
31
|
+
*
|
|
32
|
+
* @returns {ReadonlySet<string>}
|
|
33
|
+
*/
|
|
34
|
+
export function relayPaths() {
|
|
35
|
+
return new Set(
|
|
36
|
+
(process.env.WS_PROXY_PATHS ?? "")
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((entry) => entry.trim())
|
|
39
|
+
.filter((entry) => entry.startsWith("/")),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Where upgrades are sent. Same target the HTTP proxy uses. */
|
|
44
|
+
export function backendBaseUrl() {
|
|
45
|
+
return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalize a client address for the forwarded header: map loopback to a clean IPv4
|
|
50
|
+
* value and unwrap IPv4-mapped addresses.
|
|
51
|
+
*
|
|
52
|
+
* @param {string | undefined} address
|
|
53
|
+
* @returns {string | undefined}
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeClientIp(address) {
|
|
56
|
+
if (!address) return undefined;
|
|
57
|
+
if (address === "::1" || address === "::") return "127.0.0.1";
|
|
58
|
+
if (address.startsWith("::ffff:")) return address.slice("::ffff:".length);
|
|
59
|
+
return address;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Should this upgrade be forwarded, and under which normalized path?
|
|
64
|
+
*
|
|
65
|
+
* The path is re-serialised from `URL` rather than echoed, so a target that only
|
|
66
|
+
* looks like an allowed path after resolving `..` cannot reach anything else. A
|
|
67
|
+
* request target carrying its own authority is refused outright.
|
|
68
|
+
*
|
|
69
|
+
* @param {string | undefined} url `req.url`, an origin-form request target
|
|
70
|
+
* @param {Headers} headers
|
|
71
|
+
* @param {ReadonlySet<string>} [allowed]
|
|
72
|
+
* @returns {UpgradeDecision}
|
|
73
|
+
*/
|
|
74
|
+
export function decideUpgrade(url, headers, allowed = relayPaths()) {
|
|
75
|
+
const upgrade = String(headers.upgrade ?? "").toLowerCase();
|
|
76
|
+
if (upgrade !== "websocket") return { kind: "reject", reason: "not a websocket upgrade" };
|
|
77
|
+
if (!url || !url.startsWith("/") || url.startsWith("//")) {
|
|
78
|
+
return { kind: "reject", reason: "not an origin-form request target" };
|
|
79
|
+
}
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(url, "http://upgrade.invalid");
|
|
83
|
+
} catch {
|
|
84
|
+
return { kind: "reject", reason: "unparsable request target" };
|
|
85
|
+
}
|
|
86
|
+
if (!allowed.has(parsed.pathname)) return { kind: "reject", reason: "not an allowed upgrade path" };
|
|
87
|
+
return { kind: "proxy", path: `${parsed.pathname}${parsed.search}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What the API is told about the request.
|
|
92
|
+
*
|
|
93
|
+
* Handshake headers and the session cookie pass through untouched — the API
|
|
94
|
+
* authorises the socket from them, so dropping either turns every connection into a
|
|
95
|
+
* 401. The `x-forwarded-*` trio is appended rather than replaced: behind another
|
|
96
|
+
* proxy this server is one hop in a chain, and overwriting it reports the wrong
|
|
97
|
+
* client upstream.
|
|
98
|
+
*
|
|
99
|
+
* @param {Headers} headers
|
|
100
|
+
* @param {{ remoteAddress?: string, encrypted?: boolean }} socket
|
|
101
|
+
* @returns {Headers}
|
|
102
|
+
*/
|
|
103
|
+
export function upstreamHeaders(headers, socket) {
|
|
104
|
+
/** @type {Headers} */
|
|
105
|
+
const forwarded = {};
|
|
106
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
107
|
+
if (value === undefined) continue;
|
|
108
|
+
if (name === "x-forwarded-for" || name === "x-forwarded-proto" || name === "x-forwarded-host") {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
forwarded[name] = value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const client = normalizeClientIp(socket.remoteAddress);
|
|
115
|
+
const chain = [headers["x-forwarded-for"], client]
|
|
116
|
+
.flat()
|
|
117
|
+
.filter((entry) => typeof entry === "string" && entry.length > 0);
|
|
118
|
+
if (chain.length > 0) forwarded["x-forwarded-for"] = chain.join(", ");
|
|
119
|
+
|
|
120
|
+
forwarded["x-forwarded-proto"] = headers["x-forwarded-proto"] ?? (socket.encrypted ? "https" : "http");
|
|
121
|
+
const host = headers["x-forwarded-host"] ?? headers.host;
|
|
122
|
+
if (host !== undefined) forwarded["x-forwarded-host"] = host;
|
|
123
|
+
|
|
124
|
+
return forwarded;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} path
|
|
129
|
+
* @param {Headers} headers
|
|
130
|
+
*/
|
|
131
|
+
export function upstreamOptions(path, headers) {
|
|
132
|
+
const base = new URL(backendBaseUrl());
|
|
133
|
+
return {
|
|
134
|
+
protocol: base.protocol,
|
|
135
|
+
hostname: base.hostname,
|
|
136
|
+
port: base.port || (base.protocol === "https:" ? "443" : "80"),
|
|
137
|
+
path,
|
|
138
|
+
headers,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} statusLine
|
|
144
|
+
* @param {Headers} headers
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
function rawHead(statusLine, headers) {
|
|
148
|
+
const lines = [statusLine];
|
|
149
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
150
|
+
if (value === undefined) continue;
|
|
151
|
+
if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`);
|
|
152
|
+
else lines.push(`${name}: ${value}`);
|
|
153
|
+
}
|
|
154
|
+
return `${lines.join("\r\n")}\r\n\r\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Streaming sockets carry many small frames, so Nagle's algorithm adds latency to
|
|
159
|
+
* every one, and the default timeout would cut an idle-but-attached connection.
|
|
160
|
+
*
|
|
161
|
+
* @param {{ setTimeout?: Function, setNoDelay?: Function, setKeepAlive?: Function }} socket
|
|
162
|
+
*/
|
|
163
|
+
function tune(socket) {
|
|
164
|
+
socket.setTimeout?.(0);
|
|
165
|
+
socket.setNoDelay?.(true);
|
|
166
|
+
socket.setKeepAlive?.(true, 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The real transport: one upstream HTTP request carrying the upgrade. */
|
|
170
|
+
const nodeConnect = (options, hooks) => {
|
|
171
|
+
const transport = options.protocol === "https:" ? https : http;
|
|
172
|
+
const upstream = transport.request({
|
|
173
|
+
protocol: options.protocol,
|
|
174
|
+
hostname: options.hostname,
|
|
175
|
+
port: options.port,
|
|
176
|
+
path: options.path,
|
|
177
|
+
method: "GET",
|
|
178
|
+
headers: options.headers,
|
|
179
|
+
});
|
|
180
|
+
upstream.on("upgrade", (response, socket, head) => {
|
|
181
|
+
hooks.onUpgrade(response.statusCode ?? 101, response.headers, socket, head);
|
|
182
|
+
});
|
|
183
|
+
upstream.on("response", (response) => {
|
|
184
|
+
hooks.onResponse(response.statusCode ?? 502, response.statusMessage ?? "", response.headers);
|
|
185
|
+
response.resume(); // Drain, or the socket is never released.
|
|
186
|
+
});
|
|
187
|
+
upstream.on("error", (error) => hooks.onError(error.message));
|
|
188
|
+
upstream.end();
|
|
189
|
+
return { abort: () => upstream.destroy() };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @typedef {object} UpstreamHooks
|
|
194
|
+
* @property {(status: number, headers: Headers, socket: any, head: Uint8Array | null) => void} onUpgrade
|
|
195
|
+
* @property {(status: number, statusMessage: string, headers: Headers) => void} onResponse
|
|
196
|
+
* @property {(reason: string) => void} onError
|
|
197
|
+
*/
|
|
198
|
+
|
|
199
|
+
/** @typedef {(options: ReturnType<typeof upstreamOptions>, hooks: UpstreamHooks) => { abort: () => void }} Connect */
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build the `upgrade` listener the built server mounts.
|
|
203
|
+
*
|
|
204
|
+
* The transport is injected so the decisions here are testable without a socket.
|
|
205
|
+
*
|
|
206
|
+
* @param {{ connect?: Connect, allowed?: ReadonlySet<string> }} [options]
|
|
207
|
+
*/
|
|
208
|
+
export function createUpgradeProxy(options = {}) {
|
|
209
|
+
const connect = options.connect ?? nodeConnect;
|
|
210
|
+
const allowed = options.allowed ?? relayPaths();
|
|
211
|
+
|
|
212
|
+
return function onUpgrade(request, socket, head) {
|
|
213
|
+
const decision = decideUpgrade(request.url, request.headers, allowed);
|
|
214
|
+
if (decision.kind === "reject") {
|
|
215
|
+
socket.destroy();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
tune(socket);
|
|
220
|
+
// Bytes the client already sent belong to the stream: push them back so the pipe
|
|
221
|
+
// below carries them upstream in order.
|
|
222
|
+
if (head && head.length > 0) socket.unshift(head);
|
|
223
|
+
|
|
224
|
+
let clientGone = false;
|
|
225
|
+
const upstream = connect(
|
|
226
|
+
upstreamOptions(decision.path, upstreamHeaders(request.headers, socket)),
|
|
227
|
+
{
|
|
228
|
+
onUpgrade(status, headers, upstreamSocket, upstreamHead) {
|
|
229
|
+
if (clientGone) {
|
|
230
|
+
upstreamSocket.destroy();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (upstreamHead && upstreamHead.length > 0) upstreamSocket.unshift(upstreamHead);
|
|
234
|
+
tune(upstreamSocket);
|
|
235
|
+
socket.write(rawHead(`HTTP/1.1 ${status} Switching Protocols`, headers));
|
|
236
|
+
// A client that hangs forever is worse than one told it lost the relay, so
|
|
237
|
+
// either end closing takes the other down.
|
|
238
|
+
upstreamSocket.on("error", () => socket.destroy());
|
|
239
|
+
upstreamSocket.on("close", () => socket.end());
|
|
240
|
+
upstreamSocket.pipe(socket);
|
|
241
|
+
socket.pipe(upstreamSocket);
|
|
242
|
+
},
|
|
243
|
+
onResponse(status, statusMessage, headers) {
|
|
244
|
+
// The API refused the handshake. Relaying ITS answer is what turns "the
|
|
245
|
+
// socket vanished" into a diagnosable failure in the browser.
|
|
246
|
+
socket.write(
|
|
247
|
+
rawHead(`HTTP/1.1 ${status} ${statusMessage}`.trim(), {
|
|
248
|
+
...headers,
|
|
249
|
+
connection: "close",
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
socket.end();
|
|
253
|
+
},
|
|
254
|
+
onError() {
|
|
255
|
+
socket.write(rawHead("HTTP/1.1 502 Bad Gateway", { connection: "close" }));
|
|
256
|
+
socket.end();
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
socket.on("error", () => upstream.abort());
|
|
262
|
+
socket.on("close", () => {
|
|
263
|
+
clientGone = true;
|
|
264
|
+
upstream.abort();
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { PassThrough } from "node:stream";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createUpgradeProxy, decideUpgrade, upstreamHeaders } from "./ws-upgrade.js";
|
|
4
|
+
|
|
5
|
+
const allowed = new Set(["/events/ws"]);
|
|
6
|
+
const handshake = { upgrade: "websocket", connection: "Upgrade", host: "app.example.com" };
|
|
7
|
+
|
|
8
|
+
describe("decideUpgrade", () => {
|
|
9
|
+
it("forwards an allowed path", () => {
|
|
10
|
+
expect(decideUpgrade("/events/ws", handshake, allowed)).toEqual({
|
|
11
|
+
kind: "proxy",
|
|
12
|
+
path: "/events/ws",
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps the query string", () => {
|
|
17
|
+
expect(decideUpgrade("/events/ws?room=1", handshake, allowed)).toEqual({
|
|
18
|
+
kind: "proxy",
|
|
19
|
+
path: "/events/ws?room=1",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([
|
|
24
|
+
["/events/wsx", "a path that only starts the same"],
|
|
25
|
+
["/events/ws/", "a trailing slash"],
|
|
26
|
+
["/admin/secrets", "an unrelated path"],
|
|
27
|
+
["/events/../admin/secrets", "traversal that resolves elsewhere"],
|
|
28
|
+
["//evil.example.com/events/ws", "a protocol-relative target"],
|
|
29
|
+
["http://evil.example.com/events/ws", "an absolute target"],
|
|
30
|
+
])("rejects %s (%s)", (url) => {
|
|
31
|
+
expect(decideUpgrade(url, handshake, allowed).kind).toBe("reject");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects a request that is not a websocket upgrade", () => {
|
|
35
|
+
expect(decideUpgrade("/events/ws", { ...handshake, upgrade: "h2c" }, allowed).kind).toBe(
|
|
36
|
+
"reject",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("forwards nothing when no path is configured", () => {
|
|
41
|
+
expect(decideUpgrade("/events/ws", handshake, new Set()).kind).toBe("reject");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("upstreamHeaders", () => {
|
|
46
|
+
it("passes the handshake and cookie through", () => {
|
|
47
|
+
const headers = upstreamHeaders(
|
|
48
|
+
{ ...handshake, cookie: "session=abc", "sec-websocket-key": "k" },
|
|
49
|
+
{},
|
|
50
|
+
);
|
|
51
|
+
expect(headers.cookie).toBe("session=abc");
|
|
52
|
+
expect(headers["sec-websocket-key"]).toBe("k");
|
|
53
|
+
expect(headers.upgrade).toBe("websocket");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("appends to an existing forwarded chain rather than replacing it", () => {
|
|
57
|
+
const headers = upstreamHeaders(
|
|
58
|
+
{ ...handshake, "x-forwarded-for": "203.0.113.7" },
|
|
59
|
+
{ remoteAddress: "::ffff:10.0.0.2" },
|
|
60
|
+
);
|
|
61
|
+
expect(headers["x-forwarded-for"]).toBe("203.0.113.7, 10.0.0.2");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reports the scheme the client actually used", () => {
|
|
65
|
+
expect(upstreamHeaders(handshake, { encrypted: true })["x-forwarded-proto"]).toBe("https");
|
|
66
|
+
expect(upstreamHeaders(handshake, {})["x-forwarded-proto"]).toBe("http");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("createUpgradeProxy", () => {
|
|
71
|
+
function socket() {
|
|
72
|
+
const stream = new PassThrough();
|
|
73
|
+
stream.destroy = () => {
|
|
74
|
+
stream.destroyed = true;
|
|
75
|
+
return stream;
|
|
76
|
+
};
|
|
77
|
+
return stream;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("destroys a rejected upgrade without contacting the API", () => {
|
|
81
|
+
let called = false;
|
|
82
|
+
const proxy = createUpgradeProxy({ allowed, connect: () => ((called = true), { abort() {} }) });
|
|
83
|
+
const client = socket();
|
|
84
|
+
proxy({ url: "/admin/secrets", headers: handshake }, client, null);
|
|
85
|
+
expect(called).toBe(false);
|
|
86
|
+
expect(client.destroyed).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("relays the API's refusal instead of dropping the socket", () => {
|
|
90
|
+
const proxy = createUpgradeProxy({
|
|
91
|
+
allowed,
|
|
92
|
+
connect: (_options, hooks) => {
|
|
93
|
+
hooks.onResponse(401, "Unauthorized", { "content-length": "0" });
|
|
94
|
+
return { abort() {} };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const client = socket();
|
|
98
|
+
/** @type {string[]} */
|
|
99
|
+
const written = [];
|
|
100
|
+
client.on("data", (chunk) => written.push(chunk.toString()));
|
|
101
|
+
proxy({ url: "/events/ws", headers: handshake }, client, null);
|
|
102
|
+
expect(written.join("")).toContain("HTTP/1.1 401 Unauthorized");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("answers 502 when the API cannot be reached", () => {
|
|
106
|
+
const proxy = createUpgradeProxy({
|
|
107
|
+
allowed,
|
|
108
|
+
connect: (_options, hooks) => (hooks.onError("ECONNREFUSED"), { abort() {} }),
|
|
109
|
+
});
|
|
110
|
+
const client = socket();
|
|
111
|
+
/** @type {string[]} */
|
|
112
|
+
const written = [];
|
|
113
|
+
client.on("data", (chunk) => written.push(chunk.toString()));
|
|
114
|
+
proxy({ url: "/events/ws", headers: handshake }, client, null);
|
|
115
|
+
expect(written.join("")).toContain("HTTP/1.1 502 Bad Gateway");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("writes the 101 and pipes both directions once upstream upgrades", () => {
|
|
119
|
+
const upstream = socket();
|
|
120
|
+
const proxy = createUpgradeProxy({
|
|
121
|
+
allowed,
|
|
122
|
+
connect: (_options, hooks) => {
|
|
123
|
+
hooks.onUpgrade(101, { upgrade: "websocket" }, upstream, null);
|
|
124
|
+
return { abort() {} };
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const client = socket();
|
|
128
|
+
/** @type {string[]} */
|
|
129
|
+
const written = [];
|
|
130
|
+
client.on("data", (chunk) => written.push(chunk.toString()));
|
|
131
|
+
proxy({ url: "/events/ws", headers: handshake }, client, null);
|
|
132
|
+
expect(written.join("")).toContain("HTTP/1.1 101 Switching Protocols");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { RequestHandler } from "@sveltejs/kit";
|
|
2
|
-
import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
|
|
2
|
+
import { backendBaseUrl, proxyRequest, resolveClientIp } from "$lib/server/backend-proxy";
|
|
3
3
|
|
|
4
4
|
// Proxy the app's REST API, stripping the /api prefix (backend routes are at root).
|
|
5
5
|
const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
|
|
6
|
-
proxyRequest(
|
|
6
|
+
proxyRequest(
|
|
7
|
+
request,
|
|
8
|
+
`${backendBaseUrl()}/${params.path}${url.search}`,
|
|
9
|
+
resolveClientIp(getClientAddress),
|
|
10
|
+
);
|
|
7
11
|
|
|
8
12
|
export const GET = handler;
|
|
9
13
|
export const POST = handler;
|
package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/auth/[...all]/+server.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { RequestHandler } from "@sveltejs/kit";
|
|
2
|
-
import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
|
|
2
|
+
import { backendBaseUrl, proxyRequest, resolveClientIp } from "$lib/server/backend-proxy";
|
|
3
3
|
|
|
4
4
|
// Proxy the better-auth handler, keeping the /api/auth prefix.
|
|
5
5
|
const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
|
|
6
|
-
proxyRequest(
|
|
6
|
+
proxyRequest(
|
|
7
|
+
request,
|
|
8
|
+
`${backendBaseUrl()}/api/auth/${params.all}${url.search}`,
|
|
9
|
+
resolveClientIp(getClientAddress),
|
|
10
|
+
);
|
|
7
11
|
|
|
8
12
|
export const GET = handler;
|
|
9
13
|
export const POST = handler;
|
|
@@ -7,13 +7,23 @@ import { defineConfig } from "vite";
|
|
|
7
7
|
// browser origin so the same configuration works for both entry points.
|
|
8
8
|
const inDocker = process.env.VITE_DOCKER === "1";
|
|
9
9
|
|
|
10
|
+
// Dev parity for WebSocket upgrades. In production `server.js` proxies these paths to
|
|
11
|
+
// the API; without the same allowlist here, a feature would work in one and not the
|
|
12
|
+
// other -- which is exactly how a WebSocket gateway used to reach production dead.
|
|
13
|
+
// Set WS_PROXY_PATHS in the shell that runs vite; a .env file cannot reach this file.
|
|
14
|
+
const wsProxyPaths = (process.env.WS_PROXY_PATHS ?? "")
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((entry) => entry.trim())
|
|
17
|
+
.filter((entry) => entry.startsWith("/"));
|
|
18
|
+
const backendTarget = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
|
|
19
|
+
const wsProxy = Object.fromEntries(
|
|
20
|
+
wsProxyPaths.map((path) => [path, { target: backendTarget, ws: true, changeOrigin: false }]),
|
|
21
|
+
);
|
|
22
|
+
|
|
10
23
|
export default defineConfig({
|
|
11
24
|
plugins: [tailwindcss(), sveltekit()],
|
|
12
|
-
server:
|
|
13
|
-
? {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
allowedHosts: true,
|
|
17
|
-
}
|
|
18
|
-
: undefined,
|
|
25
|
+
server: {
|
|
26
|
+
...(inDocker ? { host: true, port: 5001, allowedHosts: true } : {}),
|
|
27
|
+
...(wsProxyPaths.length ? { proxy: wsProxy } : {}),
|
|
28
|
+
},
|
|
19
29
|
});
|
|
@@ -1,20 +1,65 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: podokit-deploy
|
|
3
|
-
description: Plan, inspect, apply, verify, and roll back PodoKit
|
|
3
|
+
description: Plan, inspect, apply, verify, and roll back PodoKit releases with an explicit deployment profile and confirmation hash, on Kubernetes or Docker Compose. Use when an agent needs to initialize a .podokit/deploy profile, diagnose a target, preview a release, perform an approved deployment, inspect rollout state, verify public endpoints, or prepare a rollback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Deploy a PodoKit application
|
|
7
7
|
|
|
8
|
+
A profile records which driver it uses, so every command below is the same for both.
|
|
9
|
+
Read the profile's `driver` first and say which one you are operating.
|
|
10
|
+
|
|
11
|
+
| Driver | Target | Use it when |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `kubernetes-helm` | a cluster context and namespace | you already run a cluster, or you need more than one node, cluster-managed rollouts, or per-workload scaling |
|
|
14
|
+
| `docker-compose` | one Docker host, local or over `ssh://` | one machine is the whole deployment and a reverse proxy in front of it terminates TLS |
|
|
15
|
+
|
|
8
16
|
1. Run `npx @podosoft/podokit status`, `npx @podosoft/podokit diff`, and `npx @podosoft/podokit doctor`. Stop if managed-file conflicts or unsupported framework versions could make the image unreliable.
|
|
9
17
|
2. Read [references/profile.md](references/profile.md) before creating or changing a deployment profile.
|
|
10
|
-
3.
|
|
11
|
-
4. Run `npx @podosoft/podokit deploy
|
|
12
|
-
5.
|
|
13
|
-
6.
|
|
14
|
-
7.
|
|
15
|
-
8.
|
|
16
|
-
9.
|
|
18
|
+
3. Make sure the images exist for that tag. Read [references/release.md](references/release.md): tagging `vX.Y.Z` runs the release workflow, which builds and pushes both images and stops there. Never apply a release whose images were not published.
|
|
19
|
+
4. Run `npx @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing secret values.
|
|
20
|
+
5. Run `npx @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
|
|
21
|
+
6. Show the user the driver, the target (context plus namespace and Helm release, or Docker context plus Compose project), the fingerprint result, the exact image digests, the current revision, the actions, the migration warning, and the plan hash.
|
|
22
|
+
7. Do not apply until the user explicitly approves that exact plan.
|
|
23
|
+
8. Apply with `npx @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
|
|
24
|
+
9. Run `npx @podosoft/podokit deploy status --profile <name> --json` and `npx @podosoft/podokit deploy verify --profile <name> --json`.
|
|
25
|
+
10. Report the exact images, the revision, running replicas, restart totals, and the verification result. Report `syncDrift` too: a non-empty value means containers are running locally synced artifacts and the release tag does not describe what is serving.
|
|
26
|
+
|
|
27
|
+
## Iterating against a deployment is a different command
|
|
28
|
+
|
|
29
|
+
If the user is changing code and wants to see it on the deployment, a release is the
|
|
30
|
+
wrong loop — it rebuilds and republishes to move a few megabytes of compiled output.
|
|
31
|
+
Use the `podokit-deploy-fast` skill (`podo deploy sync`, docker-compose driver only).
|
|
32
|
+
Reach for a release when the change needs a migration, changes runtime dependencies,
|
|
33
|
+
or is going to real users.
|
|
34
|
+
|
|
35
|
+
## The migration runs against the release that is still serving
|
|
36
|
+
|
|
37
|
+
Both drivers run the migration **before** the new containers start, and both keep the
|
|
38
|
+
previous release serving while the new one rolls out. So a migration must be
|
|
39
|
+
compatible with the code already running:
|
|
40
|
+
|
|
41
|
+
- Additive changes are safe: new tables, new nullable columns, new indexes.
|
|
42
|
+
- Dropping or renaming a column, or tightening a constraint, breaks the running
|
|
43
|
+
release the moment the migration commits — before any new code is live.
|
|
44
|
+
- Destructive changes belong in a **second** release, after the code that stopped
|
|
45
|
+
using the column has fully rolled out.
|
|
46
|
+
|
|
47
|
+
Rollback does not reverse migrations on either driver. If an older image cannot read
|
|
48
|
+
the current schema, roll forward instead.
|
|
49
|
+
|
|
50
|
+
## Rollback
|
|
51
|
+
|
|
52
|
+
Read [references/rollback.md](references/rollback.md), preview the target revision
|
|
53
|
+
without `--confirm`, show the new plan hash, and obtain explicit approval.
|
|
54
|
+
|
|
55
|
+
The compose driver can only reproduce a revision whose rendered project still matches
|
|
56
|
+
the current profile byte for byte; if the profile changed since, it refuses and tells
|
|
57
|
+
you to roll forward.
|
|
17
58
|
|
|
18
|
-
|
|
59
|
+
## Boundary
|
|
19
60
|
|
|
20
|
-
Never create or rotate cluster credentials, registry credentials, DNS
|
|
61
|
+
Never create or rotate cluster credentials, registry credentials, DNS records,
|
|
62
|
+
virtual machines, Kubernetes Secrets, env files on a deployment host, TLS
|
|
63
|
+
certificates, or backup repositories. Never put a password, token, private key,
|
|
64
|
+
connection URL containing credentials, or secret value in a profile, a command
|
|
65
|
+
argument, a log, or a report.
|
package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Deployment profile
|
|
2
2
|
|
|
3
|
-
The profile at `.podokit/deploy/<name>.json` is application-owned and contains no
|
|
3
|
+
The profile at `.podokit/deploy/<name>.json` is application-owned and contains no
|
|
4
|
+
secret values. It declares a `driver`, and that decides the shape of the rest of the
|
|
5
|
+
document.
|
|
6
|
+
|
|
7
|
+
## kubernetes-helm (default)
|
|
4
8
|
|
|
5
9
|
Initialize it with an explicit kubeconfig context:
|
|
6
10
|
|
|
@@ -35,3 +39,48 @@ Required keys:
|
|
|
35
39
|
Use `inCluster` when the generated Helm release should manage a dependency, `external` when application configuration points to an existing service, and `disabled` only when the application does not require it.
|
|
36
40
|
|
|
37
41
|
Use the same stable SemVer tag for API and web. `latest`, branch tags, and empty tags are rejected. Planning resolves every deployed tag to an immutable registry digest through Docker Buildx.
|
|
42
|
+
|
|
43
|
+
## docker-compose
|
|
44
|
+
|
|
45
|
+
Initialize it with an explicit Docker context. The context may be local or `ssh://`,
|
|
46
|
+
and it is pinned by a fingerprint of the endpoint plus the daemon ID, so repointing
|
|
47
|
+
it at a different host invalidates the profile instead of deploying somewhere else.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx @podosoft/podokit deploy init \
|
|
51
|
+
--profile production \
|
|
52
|
+
--driver docker-compose \
|
|
53
|
+
--context production \
|
|
54
|
+
--host app.example.com \
|
|
55
|
+
--secrets-dir /etc/podokit/example-app
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The fields that differ from the Kubernetes driver:
|
|
59
|
+
|
|
60
|
+
- `target`: Docker context, endpoint fingerprint, Compose project name
|
|
61
|
+
- `exposure`: `bindAddress` and `port` published from the web service. It defaults to
|
|
62
|
+
`127.0.0.1`, because the expected front door is a reverse proxy on the same host —
|
|
63
|
+
binding every interface publishes the app before TLS exists
|
|
64
|
+
- `workloads`: replicas plus Compose `cpus` and `memory` limits
|
|
65
|
+
- `dependencies`: `managed`, `external`, or `disabled`, each with a named Docker
|
|
66
|
+
volume and the path of its env file on the target host
|
|
67
|
+
- `secrets`: env-file paths and required key names, plus `registryLogin`
|
|
68
|
+
|
|
69
|
+
Credentials live in env files **on the target host**, never in the profile and never
|
|
70
|
+
in the rendered Compose project. The doctor reads only the key names, in a throwaway
|
|
71
|
+
container that mounts the file read-only. Create those files with the host's own
|
|
72
|
+
secret tooling before deploying; PodoKit does not write them.
|
|
73
|
+
|
|
74
|
+
Required keys:
|
|
75
|
+
|
|
76
|
+
| Env file | Required keys |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| API | Profile-declared keys plus enabled dependency connection keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `REDIS_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
|
79
|
+
| web | Only explicitly declared web keys; omitted by default |
|
|
80
|
+
| PostgreSQL dependency | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` |
|
|
81
|
+
| Redis dependency | `REDIS_PASSWORD` |
|
|
82
|
+
| Object storage dependency | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
|
83
|
+
|
|
84
|
+
Data volumes are declared `external`, so `docker compose down -v` cannot delete the
|
|
85
|
+
database. Editing an env file changes the containers' rollout label, so the next
|
|
86
|
+
apply actually restarts them instead of leaving stale environment values running.
|
package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Publishing the images a release deploys
|
|
2
|
+
|
|
3
|
+
`podo deploy` consumes images. Building them is `.github/workflows/release.yml`,
|
|
4
|
+
which triggers on a `v*.*.*` tag, verifies the commit, builds both images from the
|
|
5
|
+
repository root, and pushes them under that one tag. It does not roll out.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
git tag v1.2.3
|
|
9
|
+
git push origin v1.2.3
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Then plan and apply as the main skill describes. `podo deploy plan` resolves the tag
|
|
13
|
+
to an immutable digest, so a tag whose images were never pushed fails there rather
|
|
14
|
+
than half way through a rollout.
|
|
15
|
+
|
|
16
|
+
## Before the first tag: check the runner
|
|
17
|
+
|
|
18
|
+
**GitHub-hosted runners are free for public repositories only.** In a private
|
|
19
|
+
repository every minute is metered against the account's included Actions minutes and
|
|
20
|
+
billed beyond them, and image builds are usually the most expensive job a project
|
|
21
|
+
runs. A self-hosted runner consumes no Actions minutes at all.
|
|
22
|
+
|
|
23
|
+
The workflow reads the choice from a variable, so it is a setting rather than an edit:
|
|
24
|
+
|
|
25
|
+
| Variable | Unset | Notes |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| `PODOKIT_RUNNER` | `["ubuntu-latest"]` | JSON array of labels. Self-hosted example: `["self-hosted","Linux","X64"]` |
|
|
28
|
+
| `PODOKIT_DEPLOY_PROFILE` | `production` | which `.podokit/deploy/<name>.json` names the image repositories |
|
|
29
|
+
| `PODOKIT_IMAGE_PLATFORM` | `linux/amd64` | the architecture the **deployment target** runs, not the runner's |
|
|
30
|
+
|
|
31
|
+
Nothing warns when a private repository is still on the default: the workflow
|
|
32
|
+
succeeds either way and the difference appears on a bill. **When asked to set up
|
|
33
|
+
releases, check the repository's visibility first and say which case it is.**
|
|
34
|
+
|
|
35
|
+
`PODOKIT_IMAGE_PLATFORM` is the other one worth stating out loud. Building for the
|
|
36
|
+
wrong architecture produces `exec format error` at rollout — after the migration has
|
|
37
|
+
already run — and building for the right one on a runner of a different architecture
|
|
38
|
+
works but runs under emulation, which is slow enough to be mistaken for a problem
|
|
39
|
+
with Docker.
|
|
40
|
+
|
|
41
|
+
## Secrets
|
|
42
|
+
|
|
43
|
+
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD`, for the registry named by the profile's
|
|
44
|
+
`release.apiRepository`. A robot or deploy account is enough for pushing, but some
|
|
45
|
+
registries do not let a robot **create** a repository — push the first tag of a new
|
|
46
|
+
repository with an account that can, or create the repository in the registry first.
|
|
47
|
+
|
|
48
|
+
Never print either value, and never put one in a profile, a command argument, or a
|
|
49
|
+
report.
|
|
50
|
+
|
|
51
|
+
## Building by hand
|
|
52
|
+
|
|
53
|
+
Only when a tag is not the right trigger. Build for the target's architecture, tag
|
|
54
|
+
both images identically, and push before planning:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
docker build --platform linux/amd64 -f apps/api/Dockerfile -t <api-repository>:v1.2.3 .
|
|
58
|
+
docker build --platform linux/amd64 -f apps/web/Dockerfile -t <web-repository>:v1.2.3 .
|
|
59
|
+
docker push <api-repository>:v1.2.3
|
|
60
|
+
docker push <web-repository>:v1.2.3
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A bare local tag cannot be deployed: it does not match the profile's stable SemVer
|
|
64
|
+
pattern and cannot be resolved to a digest.
|