techbro 0.0.0-stage → 0.5.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 +453 -2
- package/assets/baseline-rules.yml +60 -0
- package/assets/caller-workflow.yml +26 -0
- package/assets/review-action.yml +27 -0
- package/assets/review-config.json +4 -0
- package/assets/review-workflow.yml +443 -0
- package/assets/review.mjs +721 -0
- package/bin/techbro.js +9 -0
- package/package.json +23 -4
- package/src/cli.js +690 -0
- package/src/cluster.js +134 -0
- package/src/config.js +35 -0
- package/src/doctor.js +125 -0
- package/src/github.js +152 -0
- package/src/init.js +167 -0
- package/src/kube-project.js +468 -0
- package/src/kube.js +317 -0
- package/src/managed.js +63 -0
- package/src/project.js +202 -0
- package/src/review.js +165 -0
- package/src/secret.js +80 -0
- package/src/setup.js +91 -0
- package/src/team-workflow.js +106 -0
- package/src/team.js +145 -0
- package/src/templates.js +44 -0
- package/src/upgrade.js +94 -0
- package/templates/_default/.agents/skills/README.md +36 -0
- package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
- package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
- package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
- package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
- package/templates/_default/.editorconfig +15 -0
- package/templates/_default/.env.example +9 -0
- package/templates/_default/.github/workflows/ci.yml +60 -0
- package/templates/_default/.nvmrc +1 -0
- package/templates/_default/Cargo.lock +1834 -0
- package/templates/_default/Cargo.toml +3 -0
- package/templates/_default/README.md +257 -0
- package/templates/_default/TEMPLATE.md +29 -0
- package/templates/_default/_dockerignore +8 -0
- package/templates/_default/_gitignore +8 -0
- package/templates/_default/apps/backend/Cargo.toml +22 -0
- package/templates/_default/apps/backend/Dockerfile +34 -0
- package/templates/_default/apps/backend/README.md +13 -0
- package/templates/_default/apps/backend/src/config.rs +111 -0
- package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
- package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
- package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
- package/templates/_default/apps/backend/src/error.rs +115 -0
- package/templates/_default/apps/backend/src/lib.rs +7 -0
- package/templates/_default/apps/backend/src/main.rs +71 -0
- package/templates/_default/apps/backend/src/models/item.rs +94 -0
- package/templates/_default/apps/backend/src/models/mod.rs +1 -0
- package/templates/_default/apps/backend/src/routes.rs +94 -0
- package/templates/_default/apps/backend/src/state.rs +12 -0
- package/templates/_default/apps/backend/src/views/items.rs +18 -0
- package/templates/_default/apps/backend/src/views/mod.rs +1 -0
- package/templates/_default/apps/backend/tests/api.rs +234 -0
- package/templates/_default/apps/frontend/.env.example +2 -0
- package/templates/_default/apps/frontend/.prettierignore +3 -0
- package/templates/_default/apps/frontend/.prettierrc +1 -0
- package/templates/_default/apps/frontend/Dockerfile +15 -0
- package/templates/_default/apps/frontend/README.md +39 -0
- package/templates/_default/apps/frontend/_dockerignore +3 -0
- package/templates/_default/apps/frontend/_gitignore +24 -0
- package/templates/_default/apps/frontend/components.json +25 -0
- package/templates/_default/apps/frontend/eslint.config.js +37 -0
- package/templates/_default/apps/frontend/index.html +13 -0
- package/templates/_default/apps/frontend/jsconfig.json +7 -0
- package/templates/_default/apps/frontend/nginx.conf +30 -0
- package/templates/_default/apps/frontend/package-lock.json +4338 -0
- package/templates/_default/apps/frontend/package.json +49 -0
- package/templates/_default/apps/frontend/public/favicon.svg +1 -0
- package/templates/_default/apps/frontend/src/App.jsx +11 -0
- package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
- package/templates/_default/apps/frontend/src/api/client.js +50 -0
- package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
- package/templates/_default/apps/frontend/src/api/items.js +26 -0
- package/templates/_default/apps/frontend/src/api/system.js +11 -0
- package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
- package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
- package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
- package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
- package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
- package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
- package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
- package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
- package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
- package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
- package/templates/_default/apps/frontend/src/index.css +130 -0
- package/templates/_default/apps/frontend/src/lib/config.js +2 -0
- package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
- package/templates/_default/apps/frontend/src/main.jsx +11 -0
- package/templates/_default/apps/frontend/src/models/items.js +24 -0
- package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
- package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
- package/templates/_default/apps/frontend/src/routes.jsx +17 -0
- package/templates/_default/apps/frontend/src/shadcn.css +655 -0
- package/templates/_default/apps/frontend/src/test/setup.js +1 -0
- package/templates/_default/apps/frontend/vite.config.js +25 -0
- package/templates/_default/database/Dockerfile +9 -0
- package/templates/_default/database/README.md +15 -0
- package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
- package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
- package/templates/_default/docker-compose.prod.yml +53 -0
- package/templates/_default/docker-compose.yml +69 -0
- package/templates/_default/docs/api/README.md +41 -0
- package/templates/_default/docs/api/openapi.yaml +152 -0
- package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
- package/templates/_default/docs/decisions/README.md +6 -0
- package/templates/_default/docs/deployment.md +46 -0
- package/templates/_default/docs/docker.md +63 -0
- package/templates/_default/docs/getting-started.md +214 -0
- package/templates/_default/package.json +15 -0
- package/templates/_default/rust-toolchain.toml +3 -0
- package/templates/_default/scripts/check +10 -0
- package/templates/_default/scripts/db-migrate +25 -0
- package/templates/_default/scripts/install-hooks +5 -0
- package/templates/_default/scripts/pre-push +3 -0
- package/templates/_default/scripts/setup +26 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Project-level Kubernetes operations: manifest generation, in-cluster build, deploy.
|
|
2
|
+
// Never reads secret values; secrets are referenced by name only. No YAML dependency.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { link, lstat, mkdir, mkdtemp, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { kubeArgs, kubectl, validateName } from "./kube.js";
|
|
10
|
+
|
|
11
|
+
const need = (ok, msg) => {
|
|
12
|
+
if (!ok) throw new Error(msg);
|
|
13
|
+
};
|
|
14
|
+
const IMAGE_RE =
|
|
15
|
+
/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?(:[0-9]+)?(\/[a-z0-9]+([._-][a-z0-9]+)*)+(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}|@sha256:[a-f0-9]{64})$/;
|
|
16
|
+
const REPO_RE = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?(:[0-9]+)?(\/[a-z0-9]+([._-][a-z0-9]+)*)+$/;
|
|
17
|
+
const MIN_MI = 256;
|
|
18
|
+
const MAX_MI = 1024;
|
|
19
|
+
|
|
20
|
+
// Env name -> key in the profile's creds secret. Names only; values are never read.
|
|
21
|
+
export const SECRET_ENV = Object.freeze([
|
|
22
|
+
["DATABASE_URL", "DATABASE_URL_IN_CLUSTER"],
|
|
23
|
+
["VALKEY_URL", "VALKEY_URL_IN_CLUSTER"],
|
|
24
|
+
["S3_ENDPOINT", "S3_ENDPOINT_IN_CLUSTER"],
|
|
25
|
+
["S3_BUCKET", "S3_BUCKET"],
|
|
26
|
+
["S3_REGION", "S3_REGION"],
|
|
27
|
+
["AWS_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"],
|
|
28
|
+
["AWS_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export function toMi(mem) {
|
|
32
|
+
const m = /^([1-9][0-9]{0,5})(Mi|Gi)$/.exec(String(mem));
|
|
33
|
+
need(m, `invalid memory: ${mem} (use Mi or Gi)`);
|
|
34
|
+
return Number(m[1]) * (m[2] === "Gi" ? 1024 : 1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateImage(image) {
|
|
38
|
+
need(typeof image === "string" && IMAGE_RE.test(image), `image must be registry-qualified with a tag or digest: ${image}`);
|
|
39
|
+
need(!/:latest$/.test(image), "mutable tag 'latest' is not allowed");
|
|
40
|
+
return image;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Secret names come only from the effective profile; no cluster-specific fallback.
|
|
44
|
+
function secretNames(profile) {
|
|
45
|
+
const refs = profile.secretRefs ?? {};
|
|
46
|
+
return { creds: refs.creds, ca: refs.ca };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function manifests({
|
|
50
|
+
profile,
|
|
51
|
+
app,
|
|
52
|
+
image,
|
|
53
|
+
port = 3000,
|
|
54
|
+
withCreds = false,
|
|
55
|
+
healthPath = "/health",
|
|
56
|
+
memory,
|
|
57
|
+
replicas = 1,
|
|
58
|
+
ingress,
|
|
59
|
+
...rest
|
|
60
|
+
} = {}) {
|
|
61
|
+
need(Object.keys(rest).length === 0, `unknown option: ${Object.keys(rest)[0]}`);
|
|
62
|
+
need(profile && typeof profile === "object", "profile required");
|
|
63
|
+
validateName(app, "app name");
|
|
64
|
+
validateName(profile.namespace, "namespace");
|
|
65
|
+
validateImage(image);
|
|
66
|
+
need(Number.isInteger(port) && port >= 1 && port <= 65535, "port must be 1-65535");
|
|
67
|
+
need(Number.isInteger(replicas) && replicas >= 1 && replicas <= 3, "replicas must be 1-3");
|
|
68
|
+
need(healthPath === null || (typeof healthPath === "string" && /^\/[A-Za-z0-9._~\/-]*$/.test(healthPath)), "invalid healthPath");
|
|
69
|
+
const mem = memory ?? profile.memory ?? "512Mi";
|
|
70
|
+
const mi = toMi(mem);
|
|
71
|
+
need(mi >= MIN_MI && mi <= MAX_MI, "memory limit must be between 256Mi and 1Gi");
|
|
72
|
+
const reqMem = `${Math.min(128, mi)}Mi`;
|
|
73
|
+
const wantIngress = ingress ?? Boolean(profile.domain);
|
|
74
|
+
need(!wantIngress || profile.domain, "ingress requires profile.domain");
|
|
75
|
+
const host = `${app}-${profile.namespace}.${profile.domain}`;
|
|
76
|
+
if (wantIngress) need(`${app}-${profile.namespace}`.length <= 63 && host.length <= 253, "ingress host too long");
|
|
77
|
+
|
|
78
|
+
const labels = { app, "app.kubernetes.io/managed-by": "techbro" };
|
|
79
|
+
const container = {
|
|
80
|
+
name: app,
|
|
81
|
+
image,
|
|
82
|
+
ports: [{ containerPort: port }],
|
|
83
|
+
env: [{ name: "PORT", value: String(port) }],
|
|
84
|
+
securityContext: { privileged: false, allowPrivilegeEscalation: false },
|
|
85
|
+
resources: { requests: { cpu: "100m", memory: reqMem }, limits: { memory: mem } },
|
|
86
|
+
};
|
|
87
|
+
if (healthPath !== null) container.readinessProbe = { httpGet: { path: healthPath, port }, periodSeconds: 10 };
|
|
88
|
+
const podSpec = { automountServiceAccountToken: false, containers: [container] };
|
|
89
|
+
|
|
90
|
+
if (withCreds) {
|
|
91
|
+
const { creds, ca } = secretNames(profile);
|
|
92
|
+
need(creds, "withCreds requires profile.secretRefs.creds (no secret references configured)");
|
|
93
|
+
validateName(creds, "secretRefs.creds");
|
|
94
|
+
for (const [name, key] of SECRET_ENV) {
|
|
95
|
+
container.env.push({ name, valueFrom: { secretKeyRef: { name: creds, key } } });
|
|
96
|
+
}
|
|
97
|
+
if (ca) {
|
|
98
|
+
validateName(ca, "secretRefs.ca");
|
|
99
|
+
const caFile = "/etc/techbro-ca/ca.crt";
|
|
100
|
+
container.env.push({ name: "NODE_EXTRA_CA_CERTS", value: caFile });
|
|
101
|
+
if (profile.caEnv) container.env.push({ name: profile.caEnv, value: caFile });
|
|
102
|
+
container.volumeMounts = [{ name: "ca", mountPath: "/etc/techbro-ca", readOnly: true }];
|
|
103
|
+
podSpec.volumes = [{ name: "ca", secret: { secretName: ca } }];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const items = [
|
|
108
|
+
{
|
|
109
|
+
apiVersion: "apps/v1",
|
|
110
|
+
kind: "Deployment",
|
|
111
|
+
metadata: { name: app, labels },
|
|
112
|
+
spec: { replicas, selector: { matchLabels: { app } }, template: { metadata: { labels }, spec: podSpec } },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
apiVersion: "v1",
|
|
116
|
+
kind: "Service",
|
|
117
|
+
metadata: { name: app, labels },
|
|
118
|
+
spec: { selector: { app }, ports: [{ port: 80, targetPort: port }] },
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
if (wantIngress) {
|
|
122
|
+
const metadata = { name: app, labels };
|
|
123
|
+
if (profile.tlsResolver) metadata.annotations = { "traefik.ingress.kubernetes.io/router.tls.certresolver": profile.tlsResolver };
|
|
124
|
+
items.push({
|
|
125
|
+
apiVersion: "networking.k8s.io/v1",
|
|
126
|
+
kind: "Ingress",
|
|
127
|
+
metadata,
|
|
128
|
+
spec: {
|
|
129
|
+
rules: [{ host, http: { paths: [{ path: "/", pathType: "Prefix", backend: { service: { name: app, port: { number: 80 } } } }] } }],
|
|
130
|
+
tls: [{ hosts: [host] }],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { apiVersion: "v1", kind: "List", items };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DANGEROUS_KEYS = new Set(["hostPath", "hostNetwork", "hostPID", "hostIPC", "hostPort", "hostAliases", "nodeName", "serviceAccountName"]);
|
|
138
|
+
|
|
139
|
+
// Defensive allow-list check applied to anything about to be sent to the cluster.
|
|
140
|
+
export function validateManifests(list, { profile, app }) {
|
|
141
|
+
need(list?.kind === "List" && Array.isArray(list.items), "expected a Kubernetes List");
|
|
142
|
+
const kinds = new Set();
|
|
143
|
+
const walk = (v) => {
|
|
144
|
+
if (Array.isArray(v)) return v.forEach(walk);
|
|
145
|
+
if (!v || typeof v !== "object") return;
|
|
146
|
+
for (const [k, x] of Object.entries(v)) {
|
|
147
|
+
need(!DANGEROUS_KEYS.has(k), `forbidden field: ${k}`);
|
|
148
|
+
need(!(k === "privileged" && x === true), "privileged containers are not allowed");
|
|
149
|
+
walk(x);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
for (const it of list.items) {
|
|
153
|
+
need(["Deployment", "Service", "Ingress"].includes(it.kind), `resource kind not allowed: ${it.kind}`);
|
|
154
|
+
need(!kinds.has(it.kind), `duplicate ${it.kind}`);
|
|
155
|
+
kinds.add(it.kind);
|
|
156
|
+
need(it.metadata?.name === app, `${it.kind} name must be ${app}`);
|
|
157
|
+
need(it.metadata.namespace == null || it.metadata.namespace === profile.namespace, "resource namespace must match the target namespace");
|
|
158
|
+
walk(it);
|
|
159
|
+
}
|
|
160
|
+
need(kinds.has("Deployment") && kinds.has("Service"), "Deployment and Service are required");
|
|
161
|
+
return list;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function generateManifest({ root = process.cwd(), force = false, ...opts } = {}) {
|
|
165
|
+
const list = manifests(opts);
|
|
166
|
+
const name = opts.profile.name;
|
|
167
|
+
need(typeof name === "string", "profile.name required");
|
|
168
|
+
validateName(name, "profile name");
|
|
169
|
+
const dir = path.join(root, "k8s");
|
|
170
|
+
const file = path.join(dir, `${name}.json`);
|
|
171
|
+
const rel = path.join("k8s", `${name}.json`);
|
|
172
|
+
const info = async (p) => lstat(p).catch((e) => (e.code === "ENOENT" ? null : Promise.reject(e)));
|
|
173
|
+
const d = await info(dir);
|
|
174
|
+
if (d) need(!d.isSymbolicLink() && d.isDirectory(), "k8s must be a real directory, not a symlink");
|
|
175
|
+
else await mkdir(dir);
|
|
176
|
+
const t = await info(file);
|
|
177
|
+
if (t) {
|
|
178
|
+
need(!t.isSymbolicLink() && t.isFile(), `${rel} must be a regular file, not a symlink`);
|
|
179
|
+
need(force, `${rel} exists; use --force to overwrite`);
|
|
180
|
+
}
|
|
181
|
+
const tmp = path.join(dir, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
|
|
182
|
+
await writeFile(tmp, `${JSON.stringify(list, null, 2)}\n`, { flag: "wx" });
|
|
183
|
+
try {
|
|
184
|
+
if (force) await rename(tmp, file);
|
|
185
|
+
else {
|
|
186
|
+
await link(tmp, file).catch((e) => {
|
|
187
|
+
throw e.code === "EEXIST" ? new Error(`${rel} exists; use --force to overwrite`) : e;
|
|
188
|
+
});
|
|
189
|
+
await unlink(tmp);
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
await rm(tmp, { force: true });
|
|
193
|
+
throw e;
|
|
194
|
+
}
|
|
195
|
+
return { path: file, manifests: list };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Safe subprocess runner: argv only, no shell, bounded output, timeout kill, optional stdin file.
|
|
199
|
+
export function safeRun(file, args, { input, inputFile, timeout = 60000, signal } = {}) {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
if (signal?.aborted) return resolve({ code: 130, stdout: "", stderr: "aborted" });
|
|
202
|
+
const cap = 4 * 1024 * 1024;
|
|
203
|
+
let out = "";
|
|
204
|
+
let err = "";
|
|
205
|
+
let done = false;
|
|
206
|
+
const child = spawn(file, args, { stdio: [input != null || inputFile ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
207
|
+
const finish = (r) => {
|
|
208
|
+
if (done) return;
|
|
209
|
+
done = true;
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
signal?.removeEventListener("abort", onAbort);
|
|
212
|
+
resolve(r);
|
|
213
|
+
};
|
|
214
|
+
const timer = setTimeout(() => {
|
|
215
|
+
child.kill("SIGKILL");
|
|
216
|
+
finish({ code: 124, stdout: out, stderr: `${err}\ntimed out after ${timeout}ms` });
|
|
217
|
+
}, timeout);
|
|
218
|
+
const onAbort = () => {
|
|
219
|
+
child.kill("SIGKILL");
|
|
220
|
+
finish({ code: 130, stdout: out, stderr: `${err}\naborted` });
|
|
221
|
+
};
|
|
222
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
223
|
+
child.stdout.on("data", (d) => void (out = (out + d).slice(-cap)));
|
|
224
|
+
child.stderr.on("data", (d) => void (err = (err + d).slice(-cap)));
|
|
225
|
+
child.on("error", (e) => finish({ code: 127, stdout: out, stderr: e.message }));
|
|
226
|
+
child.on("close", (code) => finish({ code: code ?? 1, stdout: out, stderr: err }));
|
|
227
|
+
if (child.stdin) {
|
|
228
|
+
child.stdin.on("error", () => {});
|
|
229
|
+
if (inputFile) {
|
|
230
|
+
const rs = createReadStream(inputFile);
|
|
231
|
+
rs.on("error", () => child.kill("SIGKILL"));
|
|
232
|
+
child.on("close", () => rs.destroy());
|
|
233
|
+
rs.pipe(child.stdin);
|
|
234
|
+
}
|
|
235
|
+
else child.stdin.end(input);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const SECRET_NAME = /(^|\/)(\.env(rc)?(\..*)?|.*\.(pem|key|p12|pfx|kdbx|jks|keystore|tfvars|env|db|sqlite3?)|.*\.tfstate(\..*)?|\.dockerconfigjson|id_(rsa|dsa|ecdsa|ed25519)(\..*)?|credentials(\..*)?|kubeconfig(\..*)?|\.netrc|\.npmrc|\.pypirc|\.git-credentials|service-account.*\.json)$/i;
|
|
241
|
+
const ENV_TEMPLATE = /(^|\/)\.env\.(example|sample|template)$/i;
|
|
242
|
+
const EXCLUDES = [".git", "node_modules", "target", "dist", ".venv", ".next"];
|
|
243
|
+
|
|
244
|
+
export const isSecretPath = (p) => SECRET_NAME.test(p) && !ENV_TEMPLATE.test(p);
|
|
245
|
+
|
|
246
|
+
export const BUILD_FILE_POLICY =
|
|
247
|
+
"committed source only (git archive HEAD); .dockerignore is NOT an upload filter; clean tree required; tracked secrets/symlinks/submodules rejected; node_modules, target, dist, .venv, .next excluded";
|
|
248
|
+
|
|
249
|
+
const gitOut = async (run, root, args) => {
|
|
250
|
+
const r = await run("git", ["-C", root, ...args], { timeout: 60000 });
|
|
251
|
+
need(r.code === 0, `git ${args[0]} failed: ${String(r.stderr ?? "").trim() || `exit ${r.code}`}`);
|
|
252
|
+
return String(r.stdout ?? "");
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export async function inspectContext({ root, dockerfile = "Dockerfile", maxBytes = Infinity, run = safeRun }) {
|
|
256
|
+
need(typeof dockerfile === "string" && dockerfile && !path.isAbsolute(dockerfile) && !dockerfile.split("/").includes("..") && !dockerfile.includes("\0") && !dockerfile.startsWith("-"),
|
|
257
|
+
"dockerfile must be a relative path inside the project");
|
|
258
|
+
const dirty = (await gitOut(run, root, ["status", "--porcelain", "-z", "--untracked-files=all"])).trim();
|
|
259
|
+
need(!dirty, "working tree is not clean; commit or stash changes (including untracked files) before building");
|
|
260
|
+
// Inspect the committed tree (HEAD), the same tree git archive packs.
|
|
261
|
+
const entries = (await gitOut(run, root, ["ls-tree", "-r", "-l", "-z", "HEAD"])).split("\0").filter(Boolean);
|
|
262
|
+
const files = [];
|
|
263
|
+
let total = 0;
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
const m = /^(\d+) (\w+) [0-9a-f]+ +(\d+|-)\t([\s\S]+)$/.exec(e);
|
|
266
|
+
need(m, "unexpected git ls-tree output");
|
|
267
|
+
need(m[1] !== "120000", `tracked symlink rejected: ${m[4]}`);
|
|
268
|
+
need(m[1] !== "160000", `tracked submodule/nested repo rejected: ${m[4]}`);
|
|
269
|
+
need(!isSecretPath(m[4]), `tracked secret-like file rejected: ${m[4]}`);
|
|
270
|
+
total += m[3] === "-" ? 0 : Number(m[3]);
|
|
271
|
+
files.push(m[4]);
|
|
272
|
+
}
|
|
273
|
+
need(total <= maxBytes, `build context too large before archiving: ${total} bytes (max ${maxBytes})`);
|
|
274
|
+
need(files.includes(dockerfile), `${dockerfile} is not a committed file in the project`);
|
|
275
|
+
return { files: files.length, dockerfile };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const stamp = (d) => d.toISOString().replace(/\D/g, "").slice(0, 14);
|
|
279
|
+
|
|
280
|
+
// Aborts on external `signal` or SIGINT/SIGTERM (handlers are restored); never calls process.exit.
|
|
281
|
+
// Failed cleanup is reported in `warnings` (and on the thrown error), never swallowed.
|
|
282
|
+
export async function buildProject({
|
|
283
|
+
profile,
|
|
284
|
+
app,
|
|
285
|
+
root = process.cwd(),
|
|
286
|
+
dockerfile = "Dockerfile",
|
|
287
|
+
imageRepo,
|
|
288
|
+
yes = false,
|
|
289
|
+
dryRun = false,
|
|
290
|
+
run = safeRun,
|
|
291
|
+
signal,
|
|
292
|
+
timeoutMs = 15 * 60 * 1000,
|
|
293
|
+
maxBytes = 500 * 1024 * 1024,
|
|
294
|
+
now = () => new Date(),
|
|
295
|
+
random = () => randomBytes(4).toString("hex"),
|
|
296
|
+
} = {}) {
|
|
297
|
+
validateName(app, "app name");
|
|
298
|
+
need(profile?.build?.image, "profile has no build configuration");
|
|
299
|
+
need(profile.platform == null || /^linux\/[a-z0-9/]+$/.test(profile.platform), "invalid platform");
|
|
300
|
+
const repo = imageRepo ?? (profile.registry ? `${profile.registry}/${profile.namespace}/${app}` : null);
|
|
301
|
+
need(repo, "profile has no registry; pass an explicit image repository (imageRepo)");
|
|
302
|
+
need(REPO_RE.test(repo), `invalid image repository: ${repo}`);
|
|
303
|
+
need(dryRun || yes === true, "build uploads source and pushes an image; confirm with --yes");
|
|
304
|
+
const ctx = await inspectContext({ root, dockerfile, maxBytes, run });
|
|
305
|
+
const id = `${stamp(now())}-${random()}`;
|
|
306
|
+
need(/^[0-9]{14}-[0-9a-f]{8}$/.test(id), "invalid build id");
|
|
307
|
+
const image = `${repo}:${id}`;
|
|
308
|
+
const pod = `tb-build-${id}`;
|
|
309
|
+
const policy = {
|
|
310
|
+
context: profile.context,
|
|
311
|
+
namespace: profile.namespace,
|
|
312
|
+
image,
|
|
313
|
+
privileged: profile.build.privileged === true,
|
|
314
|
+
buildImage: profile.build.image,
|
|
315
|
+
files: ctx.files,
|
|
316
|
+
filePolicy: BUILD_FILE_POLICY,
|
|
317
|
+
};
|
|
318
|
+
if (dryRun) return { dryRun: true, ...policy };
|
|
319
|
+
|
|
320
|
+
const ac = new AbortController();
|
|
321
|
+
let exitCode = null;
|
|
322
|
+
const abort = (code) => {
|
|
323
|
+
exitCode ??= code;
|
|
324
|
+
ac.abort();
|
|
325
|
+
};
|
|
326
|
+
const onInt = () => abort(130);
|
|
327
|
+
const onTerm = () => abort(143);
|
|
328
|
+
const onExt = () => abort(130);
|
|
329
|
+
if (signal?.aborted) onExt();
|
|
330
|
+
signal?.addEventListener("abort", onExt, { once: true });
|
|
331
|
+
process.on("SIGINT", onInt);
|
|
332
|
+
process.on("SIGTERM", onTerm);
|
|
333
|
+
|
|
334
|
+
const warnings = [];
|
|
335
|
+
let dir = null;
|
|
336
|
+
let result;
|
|
337
|
+
let failure = null;
|
|
338
|
+
try {
|
|
339
|
+
dir = await mkdtemp(path.join(tmpdir(), "techbro-build-"));
|
|
340
|
+
const tar = path.join(dir, "context.tar");
|
|
341
|
+
need(!ac.signal.aborted, "build aborted");
|
|
342
|
+
const excl = EXCLUDES.map((e) => `:(exclude,glob)**/${e}/**`);
|
|
343
|
+
await gitOut(run, root, ["archive", "--format=tar", `--output=${tar}`, "HEAD", "--", ".", ...excl]);
|
|
344
|
+
const size = (await stat(tar)).size;
|
|
345
|
+
need(size <= maxBytes, `build context too large: ${size} bytes (max ${maxBytes})`);
|
|
346
|
+
const prefix = kubeArgs(profile, ["get", "pod", pod]).slice(0, -3);
|
|
347
|
+
const script =
|
|
348
|
+
'mkdir /ctx; tar xf - -C /ctx; buildctl-daemonless.sh build --progress plain --frontend dockerfile.v0 --local context=/ctx --local dockerfile=/ctx --opt filename="$2" ' +
|
|
349
|
+
(profile.platform ? '--opt platform="$3" ' : "") +
|
|
350
|
+
'--output "type=image,name=$1,push=true,registry.insecure=$4"';
|
|
351
|
+
const overrides = {
|
|
352
|
+
spec: {
|
|
353
|
+
automountServiceAccountToken: false,
|
|
354
|
+
containers: [
|
|
355
|
+
{
|
|
356
|
+
name: "build",
|
|
357
|
+
image: profile.build.image,
|
|
358
|
+
stdin: true,
|
|
359
|
+
stdinOnce: true,
|
|
360
|
+
securityContext: { privileged: profile.build.privileged === true },
|
|
361
|
+
command: ["sh", "-ec", script, "sh", image, dockerfile, profile.platform ?? "", String(profile.build.insecure === true)],
|
|
362
|
+
resources: { limits: { memory: profile.build.memory ?? "3Gi" } },
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
const argv = [
|
|
368
|
+
...prefix,
|
|
369
|
+
"run", pod, "-i", "--rm", "--restart=Never", "--quiet",
|
|
370
|
+
`--image=${profile.build.image}`,
|
|
371
|
+
`--labels=app.kubernetes.io/managed-by=techbro-build,techbro.io/build-id=${id}`,
|
|
372
|
+
`--overrides=${JSON.stringify(overrides)}`,
|
|
373
|
+
];
|
|
374
|
+
const res = await run("kubectl", argv, { inputFile: tar, timeout: timeoutMs, signal: ac.signal });
|
|
375
|
+
if (ac.signal.aborted) throw new Error("build aborted");
|
|
376
|
+
need(res.code === 0, `build failed: ${String(res.stderr ?? "").trim().slice(-2000) || `exit ${res.code}`}`);
|
|
377
|
+
result = { dryRun: false, ...policy, output: String(res.stdout ?? "").slice(-4000) };
|
|
378
|
+
} catch (e) {
|
|
379
|
+
failure = e;
|
|
380
|
+
if (exitCode != null) e.exitCode = exitCode;
|
|
381
|
+
} finally {
|
|
382
|
+
// Signal handlers stay installed through cleanup so a repeated SIGINT/SIGTERM cannot kill it;
|
|
383
|
+
// cleanup runs are deliberately not given the (possibly aborted) build signal.
|
|
384
|
+
try {
|
|
385
|
+
try {
|
|
386
|
+
await cleanupBuildPod({ profile, pod, id, run });
|
|
387
|
+
} catch (e) {
|
|
388
|
+
warnings.push(
|
|
389
|
+
`cleanup failed for pod ${pod} (context ${profile.context}, namespace ${profile.namespace}): ${e.message}; remove manually with: kubectl ${profile.kubeconfig ? `--kubeconfig ${profile.kubeconfig} ` : ""}--context ${profile.context} --namespace ${profile.namespace} delete pod ${pod}`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (dir) {
|
|
393
|
+
try {
|
|
394
|
+
await rm(dir, { recursive: true, force: true });
|
|
395
|
+
} catch (e) {
|
|
396
|
+
warnings.push(`could not remove temp build directory ${dir}: ${e.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} finally {
|
|
400
|
+
process.off("SIGINT", onInt);
|
|
401
|
+
process.off("SIGTERM", onTerm);
|
|
402
|
+
signal?.removeEventListener("abort", onExt);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (failure) {
|
|
406
|
+
if (warnings.length) {
|
|
407
|
+
failure.warnings = warnings;
|
|
408
|
+
failure.message += `\nwarnings:\n- ${warnings.join("\n- ")}`;
|
|
409
|
+
}
|
|
410
|
+
throw failure;
|
|
411
|
+
}
|
|
412
|
+
return { ...result, warnings };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Deletes the build pod only if its labels prove techbro created it for this build id.
|
|
416
|
+
export async function cleanupBuildPod({ profile, pod, id, run = safeRun }) {
|
|
417
|
+
const out = await kubectl(profile, ["get", "pod", pod, "-o", "json", "--ignore-not-found"], { run });
|
|
418
|
+
if (!out.trim()) return false;
|
|
419
|
+
const labels = JSON.parse(out)?.metadata?.labels ?? {};
|
|
420
|
+
if (labels["app.kubernetes.io/managed-by"] !== "techbro-build" || labels["techbro.io/build-id"] !== id) return false;
|
|
421
|
+
await kubectl(profile, ["delete", "pod", pod, "--wait=false"], { run });
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function deployProject({
|
|
426
|
+
profile,
|
|
427
|
+
app,
|
|
428
|
+
yes = false,
|
|
429
|
+
dryRun = false,
|
|
430
|
+
serverValidate = false,
|
|
431
|
+
run = safeRun,
|
|
432
|
+
rolloutTimeout = "120s",
|
|
433
|
+
...opts
|
|
434
|
+
} = {}) {
|
|
435
|
+
const rt = /^([1-9][0-9]{0,3})([sm])$/.exec(rolloutTimeout);
|
|
436
|
+
need(rt, "invalid rollout timeout (use e.g. 120s or 10m)");
|
|
437
|
+
// Runner budget = kubectl's own rollout timeout + 30s margin so kubectl reports its timeout first.
|
|
438
|
+
const rolloutBudgetMs = Number(rt[1]) * (rt[2] === "m" ? 60000 : 1000) + 30000;
|
|
439
|
+
const list = validateManifests(manifests({ profile, app, ...opts }), { profile, app });
|
|
440
|
+
const input = JSON.stringify(list);
|
|
441
|
+
// The applied document is always regenerated from options above; no editable file is read.
|
|
442
|
+
const ensureManaged = async () => {
|
|
443
|
+
for (const item of list.items) {
|
|
444
|
+
const out = (await kubectl(profile, ["get", `${item.kind.toLowerCase()}/${app}`, "-o", "json", "--ignore-not-found"], { run })).trim();
|
|
445
|
+
if (!out) continue;
|
|
446
|
+
const managed = JSON.parse(out)?.metadata?.labels?.["app.kubernetes.io/managed-by"];
|
|
447
|
+
need(managed === "techbro", `existing ${item.kind} ${app} is not managed by techbro; refusing to adopt it`);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const applyArgs = ["apply", "-f", "-"];
|
|
451
|
+
const plan = [
|
|
452
|
+
`kubectl apply --dry-run=server (context ${profile.context}, namespace ${profile.namespace})`,
|
|
453
|
+
"kubectl apply",
|
|
454
|
+
`kubectl rollout status deployment/${app}`,
|
|
455
|
+
];
|
|
456
|
+
if (dryRun) {
|
|
457
|
+
const result = { dryRun: true, kinds: list.items.map((i) => i.kind), plan };
|
|
458
|
+
if (serverValidate) await ensureManaged();
|
|
459
|
+
if (serverValidate) result.serverValidation = await kubectl(profile, [...applyArgs, "--dry-run=server"], { run, input });
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
need(yes === true, "deploy changes the cluster; confirm with --yes");
|
|
463
|
+
await ensureManaged();
|
|
464
|
+
const validation = await kubectl(profile, [...applyArgs, "--dry-run=server"], { run, input });
|
|
465
|
+
const applied = await kubectl(profile, applyArgs, { run, input });
|
|
466
|
+
const rollout = await kubectl(profile, ["rollout", "status", `deployment/${app}`, `--timeout=${rolloutTimeout}`], { run, timeoutMs: rolloutBudgetMs });
|
|
467
|
+
return { dryRun: false, kinds: list.items.map((i) => i.kind), plan, validation, applied, rollout };
|
|
468
|
+
}
|