@podosoft/podokit 0.15.0 → 0.16.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/README.md +17 -0
- package/dist/add.d.ts +4 -0
- package/dist/add.js +59 -1
- package/dist/create.js +8 -1
- package/dist/deploy-profile.d.ts +92 -0
- package/dist/deploy-profile.js +603 -0
- package/dist/deploy-render.d.ts +18 -0
- package/dist/deploy-render.js +651 -0
- package/dist/deploy.d.ts +65 -0
- package/dist/deploy.js +851 -0
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +29 -5
- package/dist/index.d.ts +8 -0
- package/dist/index.js +147 -1
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +18 -1
- package/dist/lockfile.js +1 -0
- package/dist/remove.js +7 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/README.md +19 -8
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
- package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
- package/dist/templates/modules/bullmq/module.manifest.json +4 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
- package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
- package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
- package/dist/templates/modules/redis/module.manifest.json +16 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
- package/dist/templates/modules/sse/module.manifest.json +16 -3
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/README.md +12 -8
- package/dist/templates/todo/apps/api/Dockerfile +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/todo/apps/web/Dockerfile +2 -2
- package/dist/templates/todo/compose.dev.yaml +3 -5
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/todo/dot-env.example +6 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
- package/package.json +2 -2
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sse",
|
|
3
|
-
"description": "Server-Sent Events
|
|
3
|
+
"description": "Server-Sent Events with memory or Redis transport for multi-replica delivery.",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/config/redis.connection.ts",
|
|
7
|
+
"apps/api/src/health/readiness.service.ts"
|
|
8
|
+
],
|
|
5
9
|
"targetApp": "api",
|
|
6
|
-
"
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"ioredis": "^5.4.1"
|
|
12
|
+
},
|
|
13
|
+
"env": [
|
|
14
|
+
"# Server-Sent Events transport: memory for one API replica, redis for multiple replicas",
|
|
15
|
+
"SSE_TRANSPORT=memory",
|
|
16
|
+
"SSE_REDIS_CHANNEL=podokit:events",
|
|
17
|
+
"SSE_MAX_EVENT_BYTES=65536"
|
|
18
|
+
],
|
|
7
19
|
"inject": [
|
|
8
20
|
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { EventsModule } from \"./events/events.module\";" },
|
|
9
21
|
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:module-imports", "text": "EventsModule," }
|
|
@@ -11,6 +23,7 @@
|
|
|
11
23
|
"instructions": [
|
|
12
24
|
"Stream events: curl -N localhost:5002/events/stream",
|
|
13
25
|
"Publish a message (in another terminal): curl -XPOST localhost:5002/events -H 'content-type: application/json' -d '{\"message\":\"hello\"}'",
|
|
14
|
-
"
|
|
26
|
+
"Set SSE_TRANSPORT=redis for delivery across API replicas; Redis uses the shared REDIS_URL or individual Redis settings.",
|
|
27
|
+
"Inject EventsService and call publishAsync for confirmed delivery, publish for best-effort delivery, or publishLocal for events already fanned out to every replica."
|
|
15
28
|
]
|
|
16
29
|
}
|
|
@@ -70,6 +70,7 @@ Useful commands:
|
|
|
70
70
|
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
71
71
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
72
72
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
73
|
+
- `podo deploy` — plan, apply, verify, or roll back an immutable Kubernetes release. Use `.agents/skills/podokit-deploy`.
|
|
73
74
|
|
|
74
75
|
## Commit conventions
|
|
75
76
|
|
|
@@ -33,16 +33,17 @@ npx @podosoft/podokit dev logs
|
|
|
33
33
|
npx @podosoft/podokit dev down
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
`dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
|
|
37
|
+
`storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
|
|
38
|
+
You can still activate an additional Compose profile explicitly:
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
|
-
npx @podosoft/podokit dev watch
|
|
41
|
-
--profile cache --profile storage --profile queue
|
|
41
|
+
npx @podosoft/podokit dev watch --profile dev
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
Explicit profile flags are preserved for other lifecycle commands. `dev down`
|
|
45
|
+
activates all profiles while removing this project's stack and route. If it is
|
|
46
|
+
the final registered
|
|
46
47
|
project, it also removes the shared gateway and network. Source changes are synced
|
|
47
48
|
through Compose Watch, including Vite HMR on the same portless browser origin.
|
|
48
49
|
|
|
@@ -78,5 +79,8 @@ The API uses TypeORM with PostgreSQL. A sample `Todo` entity and an initial migr
|
|
|
78
79
|
|
|
79
80
|
## Deploy
|
|
80
81
|
|
|
81
|
-
Docker Compose in `infra/docker`;
|
|
82
|
-
|
|
82
|
+
Docker Compose lives in `infra/docker`; the basic k3s manifests in `infra/k3s`
|
|
83
|
+
route every public path through the web proxy. Use `podo deploy init`, `doctor`,
|
|
84
|
+
and `plan` for an exact-image Helm release on an existing cluster. Applying or
|
|
85
|
+
rolling back requires the exact confirmation hash from a fresh plan. See the
|
|
86
|
+
PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/docs/deployment.md).
|
|
@@ -16,12 +16,12 @@ RUN npm run build --workspace=apps/api
|
|
|
16
16
|
FROM node:22-alpine AS runtime
|
|
17
17
|
WORKDIR /app
|
|
18
18
|
ENV NODE_ENV=production
|
|
19
|
-
RUN addgroup -S app && adduser -S
|
|
19
|
+
RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
|
|
20
20
|
COPY --from=build /app/node_modules ./node_modules
|
|
21
21
|
COPY --from=deps /app/apps/api ./apps/api
|
|
22
22
|
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
|
23
23
|
COPY --from=build /app/apps/api/scripts ./apps/api/scripts
|
|
24
24
|
WORKDIR /app/apps/api
|
|
25
|
-
USER
|
|
25
|
+
USER 100:101
|
|
26
26
|
EXPOSE 3000
|
|
27
27
|
CMD ["node", "dist/main"]
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"start": "node dist/main",
|
|
9
9
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
10
10
|
"test": "jest --passWithNoTests",
|
|
11
|
+
"migrate:all": "node dist/database/migrate",
|
|
11
12
|
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
|
|
12
13
|
"migration:run": "npm run typeorm -- migration:run",
|
|
13
14
|
"migration:revert": "npm run typeorm -- migration:revert",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { redisConnectionOptions } from "./redis.connection";
|
|
3
|
+
|
|
4
|
+
describe("redisConnectionOptions", () => {
|
|
5
|
+
it("prefers an authenticated URL over individual settings", () => {
|
|
6
|
+
expect(
|
|
7
|
+
redisConnectionOptions({
|
|
8
|
+
REDIS_URL: "rediss://user:p%40ss@cache.example.com:6381/4",
|
|
9
|
+
REDIS_HOST: "ignored",
|
|
10
|
+
REDIS_PASSWORD: "ignored",
|
|
11
|
+
}),
|
|
12
|
+
).toEqual({
|
|
13
|
+
host: "cache.example.com",
|
|
14
|
+
port: 6381,
|
|
15
|
+
db: 4,
|
|
16
|
+
username: "user",
|
|
17
|
+
password: "p@ss",
|
|
18
|
+
tls: {},
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses authenticated host settings when a URL is absent", () => {
|
|
23
|
+
expect(
|
|
24
|
+
redisConnectionOptions({
|
|
25
|
+
REDIS_HOST: "cache",
|
|
26
|
+
REDIS_PORT: "6380",
|
|
27
|
+
REDIS_DB: "2",
|
|
28
|
+
REDIS_USERNAME: "app",
|
|
29
|
+
REDIS_PASSWORD: "secret",
|
|
30
|
+
REDIS_TLS: "true",
|
|
31
|
+
}),
|
|
32
|
+
).toEqual({
|
|
33
|
+
host: "cache",
|
|
34
|
+
port: 6380,
|
|
35
|
+
db: 2,
|
|
36
|
+
username: "app",
|
|
37
|
+
password: "secret",
|
|
38
|
+
tls: {},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not include a malformed URL value in its error", () => {
|
|
43
|
+
const secretUrl = "not-a-url-with-a-secret";
|
|
44
|
+
expect(() => redisConnectionOptions({ REDIS_URL: secretUrl })).toThrow(
|
|
45
|
+
"REDIS_URL must be a valid redis:// or rediss:// URL",
|
|
46
|
+
);
|
|
47
|
+
try {
|
|
48
|
+
redisConnectionOptions({ REDIS_URL: secretUrl });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
expect(String(error)).not.toContain(secretUrl);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface RedisConnectionOptions {
|
|
2
|
+
host: string;
|
|
3
|
+
port: number;
|
|
4
|
+
db: number;
|
|
5
|
+
username?: string;
|
|
6
|
+
password?: string;
|
|
7
|
+
tls?: Record<string, never>;
|
|
8
|
+
lazyConnect?: boolean;
|
|
9
|
+
enableReadyCheck?: boolean;
|
|
10
|
+
enableOfflineQueue?: boolean;
|
|
11
|
+
maxRetriesPerRequest?: number | null;
|
|
12
|
+
connectTimeout?: number;
|
|
13
|
+
commandTimeout?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function integer(name: string, value: string | undefined, fallback: number): number {
|
|
17
|
+
if (value === undefined || value === "") return fallback;
|
|
18
|
+
const parsed = Number(value);
|
|
19
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
20
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
21
|
+
}
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function decoded(value: string, name: string): string {
|
|
26
|
+
try {
|
|
27
|
+
return decodeURIComponent(value);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`${name} contains invalid percent encoding`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fromUrl(value: string): RedisConnectionOptions {
|
|
34
|
+
let url: URL;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(value);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("REDIS_URL must be a valid redis:// or rediss:// URL");
|
|
39
|
+
}
|
|
40
|
+
if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
|
|
41
|
+
throw new Error("REDIS_URL must use the redis:// or rediss:// protocol");
|
|
42
|
+
}
|
|
43
|
+
const dbPath = url.pathname.replace(/^\/+/, "");
|
|
44
|
+
return {
|
|
45
|
+
host: url.hostname || "localhost",
|
|
46
|
+
port: integer("REDIS_URL port", url.port, 6379),
|
|
47
|
+
db: integer("REDIS_URL database", dbPath, 0),
|
|
48
|
+
...(url.username ? { username: decoded(url.username, "REDIS_URL username") } : {}),
|
|
49
|
+
...(url.password ? { password: decoded(url.password, "REDIS_URL password") } : {}),
|
|
50
|
+
...(url.protocol === "rediss:" ? { tls: {} } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function redisConnectionOptions(
|
|
55
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
56
|
+
overrides: Partial<RedisConnectionOptions> = {},
|
|
57
|
+
): RedisConnectionOptions {
|
|
58
|
+
const base = env.REDIS_URL
|
|
59
|
+
? fromUrl(env.REDIS_URL)
|
|
60
|
+
: {
|
|
61
|
+
host: env.REDIS_HOST ?? "localhost",
|
|
62
|
+
port: integer("REDIS_PORT", env.REDIS_PORT, 6379),
|
|
63
|
+
db: integer("REDIS_DB", env.REDIS_DB, 0),
|
|
64
|
+
...(env.REDIS_USERNAME ? { username: env.REDIS_USERNAME } : {}),
|
|
65
|
+
...(env.REDIS_PASSWORD ? { password: env.REDIS_PASSWORD } : {}),
|
|
66
|
+
...(env.REDIS_TLS === "true" ? { tls: {} } : {}),
|
|
67
|
+
};
|
|
68
|
+
return { ...base, ...overrides };
|
|
69
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import dataSource from "./data-source";
|
|
2
|
+
|
|
3
|
+
async function migrate(): Promise<void> {
|
|
4
|
+
await dataSource.initialize();
|
|
5
|
+
try {
|
|
6
|
+
await dataSource.runMigrations({ transaction: "all" });
|
|
7
|
+
} finally {
|
|
8
|
+
await dataSource.destroy();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
void migrate().catch((error: unknown) => {
|
|
13
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
14
|
+
process.stderr.write(`Migration failed: ${message}\n`);
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
});
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
|
|
2
2
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
3
3
|
import { DataSource } from "typeorm";
|
|
4
|
+
import { ReadinessService, type ReadinessStatus } from "./readiness.service";
|
|
5
|
+
|
|
6
|
+
interface ReadinessResponse {
|
|
7
|
+
status: "ready";
|
|
8
|
+
db: "up";
|
|
9
|
+
checks: Record<string, ReadinessStatus>;
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
@Controller("health")
|
|
6
13
|
export class HealthController {
|
|
7
|
-
constructor(
|
|
14
|
+
constructor(
|
|
15
|
+
@InjectDataSource() private readonly dataSource: DataSource,
|
|
16
|
+
private readonly readinessService: ReadinessService,
|
|
17
|
+
) {}
|
|
8
18
|
|
|
9
19
|
@Get()
|
|
10
20
|
liveness(): { status: string; uptime: number; timestamp: string } {
|
|
@@ -12,12 +22,17 @@ export class HealthController {
|
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
@Get("ready")
|
|
15
|
-
async readiness(): Promise<
|
|
25
|
+
async readiness(): Promise<ReadinessResponse> {
|
|
26
|
+
let db: ReadinessStatus = "up";
|
|
16
27
|
try {
|
|
17
28
|
await this.dataSource.query("SELECT 1");
|
|
18
|
-
return { status: "ready", db: "up" };
|
|
19
29
|
} catch {
|
|
20
|
-
|
|
30
|
+
db = "down";
|
|
31
|
+
}
|
|
32
|
+
const checks = await this.readinessService.run();
|
|
33
|
+
if (db === "down" || Object.values(checks).includes("down")) {
|
|
34
|
+
throw new ServiceUnavailableException({ status: "degraded", db, checks });
|
|
21
35
|
}
|
|
36
|
+
return { status: "ready", db: "up", checks };
|
|
22
37
|
}
|
|
23
38
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { Module } from "@nestjs/common";
|
|
1
|
+
import { Global, Module } from "@nestjs/common";
|
|
2
2
|
import { HealthController } from "./health.controller";
|
|
3
|
+
import { ReadinessService } from "./readiness.service";
|
|
3
4
|
|
|
5
|
+
@Global()
|
|
4
6
|
@Module({
|
|
5
7
|
controllers: [HealthController],
|
|
8
|
+
providers: [ReadinessService],
|
|
9
|
+
exports: [ReadinessService],
|
|
6
10
|
})
|
|
7
11
|
export class HealthModule {}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { ReadinessService } from "./readiness.service";
|
|
3
|
+
|
|
4
|
+
describe("ReadinessService", () => {
|
|
5
|
+
it("runs registered checks and removes them with the returned callback", async () => {
|
|
6
|
+
const service = new ReadinessService();
|
|
7
|
+
const unregister = service.register("cache", () => Promise.resolve());
|
|
8
|
+
service.register("events", () => Promise.reject(new Error("unavailable")));
|
|
9
|
+
|
|
10
|
+
await expect(service.run()).resolves.toEqual({ cache: "up", events: "down" });
|
|
11
|
+
unregister();
|
|
12
|
+
await expect(service.run()).resolves.toEqual({ events: "down" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects duplicate names", () => {
|
|
16
|
+
const service = new ReadinessService();
|
|
17
|
+
service.register("cache", () => Promise.resolve());
|
|
18
|
+
expect(() => service.register("cache", () => Promise.resolve())).toThrow(
|
|
19
|
+
'Readiness check "cache" is already registered',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
|
|
3
|
+
export type ReadinessStatus = "up" | "down";
|
|
4
|
+
export type ReadinessCheck = () => Promise<void>;
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class ReadinessService {
|
|
8
|
+
private readonly checks = new Map<string, ReadinessCheck>();
|
|
9
|
+
|
|
10
|
+
register(name: string, check: ReadinessCheck): () => void {
|
|
11
|
+
if (this.checks.has(name)) {
|
|
12
|
+
throw new Error(`Readiness check "${name}" is already registered`);
|
|
13
|
+
}
|
|
14
|
+
this.checks.set(name, check);
|
|
15
|
+
return () => {
|
|
16
|
+
this.checks.delete(name);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async run(): Promise<Record<string, ReadinessStatus>> {
|
|
21
|
+
const results = await Promise.all(
|
|
22
|
+
[...this.checks].map(async ([name, check]): Promise<[string, ReadinessStatus]> => {
|
|
23
|
+
try {
|
|
24
|
+
await check();
|
|
25
|
+
return [name, "up"];
|
|
26
|
+
} catch {
|
|
27
|
+
return [name, "down"];
|
|
28
|
+
}
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
return Object.fromEntries(results);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -16,11 +16,11 @@ RUN npm run build --workspace=apps/web
|
|
|
16
16
|
FROM node:22-alpine AS runtime
|
|
17
17
|
WORKDIR /app
|
|
18
18
|
ENV NODE_ENV=production
|
|
19
|
-
RUN addgroup -S app && adduser -S
|
|
19
|
+
RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
|
|
20
20
|
COPY --from=build /app/node_modules ./node_modules
|
|
21
21
|
COPY --from=deps /app/apps/web ./apps/web
|
|
22
22
|
COPY --from=build /app/apps/web/build ./apps/web/build
|
|
23
23
|
WORKDIR /app/apps/web
|
|
24
|
-
USER
|
|
24
|
+
USER 100:101
|
|
25
25
|
EXPOSE 3000
|
|
26
26
|
CMD ["node", "build"]
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
# Containerized development environment.
|
|
2
2
|
#
|
|
3
|
-
# podo dev watch
|
|
4
|
-
# podo dev watch --profile cache --profile storage \
|
|
5
|
-
# --profile queue # with modules
|
|
3
|
+
# podo dev watch # installed modules activate their required profiles
|
|
6
4
|
#
|
|
7
5
|
# A user-level PodoKit gateway owns loopback :80 and routes each *.localhost host
|
|
8
6
|
# to this stack. The legacy per-project Traefik service is opt-in only.
|
|
9
7
|
# api/postgres/redis/minio have NO published ports — they talk by service name
|
|
10
8
|
# on the internal network, so this stack never collides with other projects.
|
|
11
9
|
#
|
|
12
|
-
# Core services (traefik, web, api, postgres) always run.
|
|
13
|
-
#
|
|
10
|
+
# Core services (traefik, web, api, postgres) always run. PodoKit activates
|
|
11
|
+
# module-specific profiles from .podokit/manifest.json:
|
|
14
12
|
# --profile cache redis (redis / rate-limit / bullmq modules)
|
|
15
13
|
# --profile storage minio (object-storage-s3 / file-upload modules)
|
|
16
14
|
# --profile queue worker (bullmq / job-progress modules)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-deploy
|
|
3
|
+
description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit deployment profile and confirmation hash. Use when an agent needs to initialize a .podokit/deploy profile, diagnose a target cluster, preview a release, perform an approved deployment, inspect rollout state, verify public endpoints, or prepare an application rollback.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deploy a PodoKit application
|
|
7
|
+
|
|
8
|
+
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
|
+
2. Read [references/profile.md](references/profile.md) before creating or changing a deployment profile.
|
|
10
|
+
3. Run `npx @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing Secret values.
|
|
11
|
+
4. Run `npx @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
|
|
12
|
+
5. Show the user the context, cluster fingerprint result, namespace, Helm release, exact images, current revision and status, actions, migration warning, and plan hash.
|
|
13
|
+
6. Do not apply until the user explicitly approves that exact plan.
|
|
14
|
+
7. Apply with `npx @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
|
|
15
|
+
8. Run `npx @podosoft/podokit deploy status --profile <name> --json` and `npx @podosoft/podokit deploy verify --profile <name> --json`.
|
|
16
|
+
9. Report the exact images, Helm revision, ready replicas, restart totals, and verification result.
|
|
17
|
+
|
|
18
|
+
For rollback, read [references/rollback.md](references/rollback.md), preview the target revision without `--confirm`, show the new plan hash, and obtain explicit approval.
|
|
19
|
+
|
|
20
|
+
Never create or rotate cluster credentials, registry credentials, DNS, virtual machines, Kubernetes Secrets, or backup repositories. Never put a password, token, private key, connection URL containing credentials, or Secret value in a profile, command argument, log, or report.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Deployment profile
|
|
2
|
+
|
|
3
|
+
The profile at `.podokit/deploy/<name>.json` is application-owned and contains no Secret values.
|
|
4
|
+
|
|
5
|
+
Initialize it with an explicit kubeconfig context:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @podosoft/podokit deploy init --profile production --context production --host app.example.com
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Review the target, immutable API/web repositories, exposure, workloads, dependency modes, API/optional web Secret names and required keys, non-sensitive runtime configuration, and verification checks before planning.
|
|
12
|
+
|
|
13
|
+
The cluster namespace and referenced Secrets must already exist. PodoKit asks `kubectl` to output only Secret key names and never prints or stores their values; the Kubernetes client still materializes the Secret while evaluating that output.
|
|
14
|
+
|
|
15
|
+
Required keys:
|
|
16
|
+
|
|
17
|
+
| Secret | Required keys |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| 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` |
|
|
20
|
+
| web | Only explicitly declared web keys; omitted by default |
|
|
21
|
+
| PostgreSQL dependency | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` |
|
|
22
|
+
| Redis dependency | `REDIS_PASSWORD` |
|
|
23
|
+
| Object storage dependency | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
|
24
|
+
| image pull | A `kubernetes.io/dockerconfigjson` Secret |
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
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.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Application rollback
|
|
2
|
+
|
|
3
|
+
Preview the selected revision without `--confirm`, show the resulting plan hash, and obtain explicit approval before rerunning the command with that hash.
|
|
4
|
+
|
|
5
|
+
Rollback changes only the application Helm release. It does not reverse database migrations, change the dependency release, delete PersistentVolumeClaims, or delete Secrets. If the old application is incompatible with the current database schema, prepare a forward fix instead.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-deploy
|
|
3
|
+
description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit profile and confirmation hash. Use for Kubernetes deployment and rollback work in a generated PodoKit project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PodoKit deployment
|
|
7
|
+
|
|
8
|
+
Read and follow the canonical skill at `../../../.agents/skills/podokit-deploy/SKILL.md`.
|
|
@@ -19,3 +19,9 @@ CORS_ORIGIN=http://localhost:5001
|
|
|
19
19
|
# docker compose --profile cache up
|
|
20
20
|
REDIS_HOST=localhost
|
|
21
21
|
REDIS_PORT=6379
|
|
22
|
+
# REDIS_URL takes precedence over the individual settings below.
|
|
23
|
+
# REDIS_URL=rediss://user:password@cache.example.com:6380/0
|
|
24
|
+
REDIS_DB=0
|
|
25
|
+
# REDIS_USERNAME=
|
|
26
|
+
# REDIS_PASSWORD=
|
|
27
|
+
REDIS_TLS=false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -44,6 +44,6 @@
|
|
|
44
44
|
"test": "vitest run"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@podosoft/podokit-template-engine": "^0.4.
|
|
47
|
+
"@podosoft/podokit-template-engine": "^0.4.2"
|
|
48
48
|
}
|
|
49
49
|
}
|