@podosoft/podokit 0.16.4 → 0.17.1
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 +3 -10
- package/dist/deploy-profile.js +82 -291
- 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/server.js +31 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -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/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 +15 -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/lib/i18n/catalogs/admin-dashboard/en.json +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +5 -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/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +20 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- 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/server.js +31 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -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/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,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
|
+
});
|
|
@@ -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.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-deploy-fast
|
|
3
|
+
description: Push locally built artifacts into a running Docker Compose deployment and restart it, without building or publishing an image. Use when iterating on code against a deployed environment and a full release round trip is too slow. Not for releases, migrations, dependency changes, or anything going to real users.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Sync code into a running deployment
|
|
7
|
+
|
|
8
|
+
`podo deploy sync` copies this project's build output into the containers that are
|
|
9
|
+
already running and restarts them. `docker-compose` driver only — read the profile's
|
|
10
|
+
`driver` first and stop if it is `kubernetes-helm`, where the equivalent would have
|
|
11
|
+
to reach every node and would stop being a shortcut.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @podosoft/podokit deploy sync --profile <name> --build
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Say what this does before doing it
|
|
18
|
+
|
|
19
|
+
It is not a release, and the user has to understand three consequences before you run
|
|
20
|
+
it:
|
|
21
|
+
|
|
22
|
+
1. **The deployment will run code its image tag does not describe.** A marker file
|
|
23
|
+
records the sync and `podo deploy status` reports it under `syncDrift`.
|
|
24
|
+
2. **Every connection the restarted containers hold is dropped** — WebSocket sessions,
|
|
25
|
+
in-flight requests, anything attached.
|
|
26
|
+
3. **It disappears on the next release.** Container writable layers do not survive
|
|
27
|
+
recreation, so `podo deploy apply` restores the image with no cleanup step. That is
|
|
28
|
+
the property that makes this safe; do not build anything that depends on the copy
|
|
29
|
+
persisting.
|
|
30
|
+
|
|
31
|
+
## When NOT to use it
|
|
32
|
+
|
|
33
|
+
Use a release instead — build the images and `podo deploy apply` — when any of these
|
|
34
|
+
is true. The first three are refusals the command makes itself; the last two it
|
|
35
|
+
cannot detect, which is why they are your job.
|
|
36
|
+
|
|
37
|
+
| Situation | Why |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| runtime dependencies changed | the container's `node_modules` was installed `--omit=dev` from the old manifest, so the new code imports something that is not there |
|
|
40
|
+
| a release holds the deployment lock | an apply is in progress |
|
|
41
|
+
| nothing is running for that project | there is no container to copy into |
|
|
42
|
+
| **the change needs a migration** | sync never migrates, and a migration belongs with the release whose code needs it |
|
|
43
|
+
| **the change is going to real users** | nothing is pushed, so nothing is reproducible from it |
|
|
44
|
+
|
|
45
|
+
## Procedure
|
|
46
|
+
|
|
47
|
+
1. Read the profile's `driver`. Stop unless it is `docker-compose`.
|
|
48
|
+
2. Tell the user what will restart and that connections drop. Get agreement if anyone
|
|
49
|
+
is using the deployment.
|
|
50
|
+
3. Run with `--build` unless the project is already built. `--build` compiles in the
|
|
51
|
+
order the images do — every `packages/*` before the apps — because a root build
|
|
52
|
+
script runs workspaces in manifest order and an app listed first would compile
|
|
53
|
+
against a workspace package's previous output.
|
|
54
|
+
4. Report what was copied, which containers restarted, and that the deployment is now
|
|
55
|
+
in sync drift.
|
|
56
|
+
5. Verify against the running deployment, not against the build: request a path the
|
|
57
|
+
change affects. If part of the build output is excluded (below), check one of
|
|
58
|
+
those paths too — that is the failure this exclusion exists to prevent, and it
|
|
59
|
+
does not make the page look broken.
|
|
60
|
+
|
|
61
|
+
## Restoring the image
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx @podosoft/podokit deploy sync --profile <name> --revert
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This recreates the containers from the Compose project **already on the target**,
|
|
68
|
+
which is the description of what was actually applied — not a fresh render, which
|
|
69
|
+
would reproduce the profile as it is now.
|
|
70
|
+
|
|
71
|
+
A release does the same thing as a side effect, so `--revert` is for abandoning a
|
|
72
|
+
sync, not a required cleanup step.
|
|
73
|
+
|
|
74
|
+
## Excluding part of the build output
|
|
75
|
+
|
|
76
|
+
Some of a build output can come from a toolchain the developer's machine does not
|
|
77
|
+
have. Overwriting that part replaces real artifacts with an index of artifacts that
|
|
78
|
+
are no longer there, and the application keeps serving pages while the missing files
|
|
79
|
+
404 — so nothing looks broken. Those paths belong in the profile:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{ "sync": { "exclude": ["apps/web/build/static-assets/generated"] } }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If you find yourself about to sync over something this machine cannot rebuild, add it
|
|
86
|
+
here rather than remembering not to. `--clean` refuses while an exclude sits beneath
|
|
87
|
+
a synced path, for the same reason.
|
|
88
|
+
|
|
89
|
+
## Boundary
|
|
90
|
+
|
|
91
|
+
Never use this to ship. Never run migrations alongside it. Never create or rotate
|
|
92
|
+
credentials, and never print a secret value.
|
|
@@ -1,6 +1,6 @@
|
|
|
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 profile and confirmation hash, on Kubernetes or Docker Compose. Use for release, rollout, and rollback work in a generated PodoKit project.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# PodoKit deployment
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-deploy-fast
|
|
3
|
+
description: Push locally built artifacts into a running Docker Compose deployment and restart it, without building or publishing an image. Use when iterating on code against a deployed environment and a full release round trip is too slow. Not for releases, migrations, dependency changes, or anything going to real users.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PodoKit fast deployment sync
|
|
7
|
+
|
|
8
|
+
Read and follow the canonical skill at `../../../.agents/skills/podokit-deploy-fast/SKILL.md`.
|
|
@@ -6,3 +6,10 @@ dist
|
|
|
6
6
|
**/.svelte-kit
|
|
7
7
|
.git
|
|
8
8
|
*.log
|
|
9
|
+
|
|
10
|
+
# Real environment files never enter a build context. The build stages copy the
|
|
11
|
+
# whole repository, so an .env left here is baked into an image layer even when the
|
|
12
|
+
# runtime stage does not copy it -- and getting-started tells you to create one.
|
|
13
|
+
.env
|
|
14
|
+
.env.*
|
|
15
|
+
!.env.example
|
|
@@ -12,6 +12,13 @@ POSTGRES_DB=podokit
|
|
|
12
12
|
|
|
13
13
|
# Web -> API boundary (server-side only; not exposed to the browser)
|
|
14
14
|
BACKEND_INTERNAL_URL=http://localhost:5002
|
|
15
|
+
|
|
16
|
+
# Exact paths the web server may proxy as WebSocket upgrades to the API, comma
|
|
17
|
+
# separated. Empty means none: a SvelteKit route cannot answer an upgrade, so a
|
|
18
|
+
# WebSocket gateway on the API is unreachable until it is named here. Each path is
|
|
19
|
+
# matched whole and must be authorised by the API at handshake time -- a relay that
|
|
20
|
+
# forwards anything is an open tunnel to every private route.
|
|
21
|
+
WS_PROXY_PATHS=
|
|
15
22
|
CORS_ORIGIN=http://localhost:5001
|
|
16
23
|
# adapter-node request limit; leave room for a 2 MiB multipart upload
|
|
17
24
|
BODY_SIZE_LIMIT=3M
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Build and publish the release images for a tag.
|
|
2
|
+
#
|
|
3
|
+
# `podo deploy` consumes images; it does not build them. This is the other half:
|
|
4
|
+
# tagging a commit produces the two images that `podo deploy apply` then rolls out.
|
|
5
|
+
# It stops at the push, because rolling out requires confirming a plan hash and that
|
|
6
|
+
# approval belongs to a person.
|
|
7
|
+
#
|
|
8
|
+
# ── Which runner, and what it costs ──────────────────────────────────────────────
|
|
9
|
+
#
|
|
10
|
+
# GitHub-hosted runners are free for PUBLIC repositories only. In a PRIVATE
|
|
11
|
+
# repository every minute is metered against the account's included Actions minutes
|
|
12
|
+
# and billed beyond them -- and image builds are the most expensive job most
|
|
13
|
+
# projects run. A self-hosted runner consumes no Actions minutes at all, whatever
|
|
14
|
+
# the repository's visibility.
|
|
15
|
+
#
|
|
16
|
+
# So the runner is a variable rather than a constant:
|
|
17
|
+
#
|
|
18
|
+
# PODOKIT_RUNNER repository or organization variable, a JSON array of labels.
|
|
19
|
+
# Unset -> ["ubuntu-latest"], which is right for a public
|
|
20
|
+
# repository and costs money in a private one.
|
|
21
|
+
# Self-hosted example: ["self-hosted","Linux","X64"]
|
|
22
|
+
#
|
|
23
|
+
# Set it before the first tag if this repository is private. Nothing here warns you
|
|
24
|
+
# when you have not: the workflow succeeds either way, and the difference shows up
|
|
25
|
+
# on a bill.
|
|
26
|
+
#
|
|
27
|
+
# ── The other two variables ──────────────────────────────────────────────────────
|
|
28
|
+
#
|
|
29
|
+
# PODOKIT_DEPLOY_PROFILE which .podokit/deploy/<name>.json names the image
|
|
30
|
+
# repositories. Unset -> production.
|
|
31
|
+
# PODOKIT_IMAGE_PLATFORM the architecture the deployment target runs.
|
|
32
|
+
# Unset -> linux/amd64. Building for the wrong one
|
|
33
|
+
# produces "exec format error" at rollout, after the
|
|
34
|
+
# migration has already run.
|
|
35
|
+
#
|
|
36
|
+
# ── Secrets ──────────────────────────────────────────────────────────────────────
|
|
37
|
+
#
|
|
38
|
+
# REGISTRY_USERNAME / REGISTRY_PASSWORD credentials for the registry named by
|
|
39
|
+
# the profile. A robot or deploy account is enough, but note that some registries
|
|
40
|
+
# refuse to let a robot CREATE a repository -- push the first tag of a new
|
|
41
|
+
# repository with an account that can, or create it in the registry first.
|
|
42
|
+
|
|
43
|
+
name: release
|
|
44
|
+
|
|
45
|
+
on:
|
|
46
|
+
push:
|
|
47
|
+
tags:
|
|
48
|
+
- "v*.*.*"
|
|
49
|
+
|
|
50
|
+
concurrency:
|
|
51
|
+
group: release-${{ github.ref }}
|
|
52
|
+
cancel-in-progress: false
|
|
53
|
+
|
|
54
|
+
jobs:
|
|
55
|
+
images:
|
|
56
|
+
# One job builds both images rather than a matrix of two. A self-hosted pool is
|
|
57
|
+
# usually small, and fanning out here takes runners away from every other
|
|
58
|
+
# repository that shares it for no wall-clock gain on a multi-core machine.
|
|
59
|
+
runs-on: ${{ fromJSON(vars.PODOKIT_RUNNER || '["ubuntu-latest"]') }}
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v5
|
|
62
|
+
|
|
63
|
+
- uses: actions/setup-node@v6
|
|
64
|
+
with:
|
|
65
|
+
node-version: 22
|
|
66
|
+
cache: npm
|
|
67
|
+
|
|
68
|
+
- run: npm ci --no-audit --no-fund
|
|
69
|
+
|
|
70
|
+
# Verify before publishing. An image built from a commit that never passed is
|
|
71
|
+
# indistinguishable from one that did, once it is in the registry.
|
|
72
|
+
- run: npm run lint
|
|
73
|
+
- run: npm test
|
|
74
|
+
|
|
75
|
+
- name: Read the release target from the deployment profile
|
|
76
|
+
id: profile
|
|
77
|
+
run: |
|
|
78
|
+
node --input-type=module -e '
|
|
79
|
+
import { readFileSync } from "node:fs";
|
|
80
|
+
const name = process.env.PROFILE || "production";
|
|
81
|
+
const path = `.podokit/deploy/${name}.json`;
|
|
82
|
+
let profile;
|
|
83
|
+
try {
|
|
84
|
+
profile = JSON.parse(readFileSync(path, "utf8"));
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error(`No deployment profile at ${path}. Run podo deploy init first.`);
|
|
87
|
+
}
|
|
88
|
+
const { apiRepository, webRepository } = profile.release ?? {};
|
|
89
|
+
if (!apiRepository || !webRepository) {
|
|
90
|
+
throw new Error(`${path} does not declare release.apiRepository and release.webRepository.`);
|
|
91
|
+
}
|
|
92
|
+
// Everything before the first slash is a registry host only when it
|
|
93
|
+
// looks like one; otherwise the reference is a Docker Hub short name.
|
|
94
|
+
const first = apiRepository.split("/")[0];
|
|
95
|
+
const registry = /[.:]/.test(first) ? first : "";
|
|
96
|
+
const out = [
|
|
97
|
+
`api=${apiRepository}`,
|
|
98
|
+
`web=${webRepository}`,
|
|
99
|
+
`registry=${registry}`,
|
|
100
|
+
`tag=${process.env.GITHUB_REF_NAME}`,
|
|
101
|
+
].join("\n");
|
|
102
|
+
process.stdout.write(out + "\n");
|
|
103
|
+
' >> "$GITHUB_OUTPUT"
|
|
104
|
+
env:
|
|
105
|
+
PROFILE: ${{ vars.PODOKIT_DEPLOY_PROFILE }}
|
|
106
|
+
|
|
107
|
+
- uses: docker/setup-buildx-action@v3
|
|
108
|
+
|
|
109
|
+
- uses: docker/login-action@v3
|
|
110
|
+
with:
|
|
111
|
+
registry: ${{ steps.profile.outputs.registry }}
|
|
112
|
+
username: ${{ secrets.REGISTRY_USERNAME }}
|
|
113
|
+
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
114
|
+
|
|
115
|
+
- name: Build and push the API image
|
|
116
|
+
uses: docker/build-push-action@v6
|
|
117
|
+
with:
|
|
118
|
+
context: .
|
|
119
|
+
file: apps/api/Dockerfile
|
|
120
|
+
platforms: ${{ vars.PODOKIT_IMAGE_PLATFORM || 'linux/amd64' }}
|
|
121
|
+
push: true
|
|
122
|
+
tags: ${{ steps.profile.outputs.api }}:${{ steps.profile.outputs.tag }}
|
|
123
|
+
cache-from: type=gha,scope=api
|
|
124
|
+
cache-to: type=gha,scope=api,mode=max
|
|
125
|
+
|
|
126
|
+
- name: Build and push the web image
|
|
127
|
+
uses: docker/build-push-action@v6
|
|
128
|
+
with:
|
|
129
|
+
context: .
|
|
130
|
+
file: apps/web/Dockerfile
|
|
131
|
+
platforms: ${{ vars.PODOKIT_IMAGE_PLATFORM || 'linux/amd64' }}
|
|
132
|
+
push: true
|
|
133
|
+
tags: ${{ steps.profile.outputs.web }}:${{ steps.profile.outputs.tag }}
|
|
134
|
+
cache-from: type=gha,scope=web
|
|
135
|
+
cache-to: type=gha,scope=web,mode=max
|
|
136
|
+
|
|
137
|
+
- name: Report what to deploy
|
|
138
|
+
run: |
|
|
139
|
+
echo "Pushed ${{ steps.profile.outputs.api }}:${{ steps.profile.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
|
|
140
|
+
echo "Pushed ${{ steps.profile.outputs.web }}:${{ steps.profile.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
|
|
141
|
+
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
142
|
+
echo 'Roll out with `podo deploy plan --profile <name> --release ${{ steps.profile.outputs.tag }}`, then apply the printed hash.' >> "$GITHUB_STEP_SUMMARY"
|