feat: implement namespace management and improve build process
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-expressions": "off",
|
||||
"nextjs/no-html-link-for-pages": "error",
|
||||
"nextjs/next-script-for-ga": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
// oxlint-disable no-unused-expressions
|
||||
import { build } from "bun";
|
||||
import { createLogger } from "./lib/logger";
|
||||
import { readdir, rename, rmdir } from "node:fs/promises";
|
||||
|
||||
build({
|
||||
compile: true,
|
||||
sourcemap: true,
|
||||
minify: true,
|
||||
target: "bun",
|
||||
entrypoints: ["index.ts"],
|
||||
outdir: "dist",
|
||||
features: ["prod"],
|
||||
});
|
||||
async function platform(p: string) {
|
||||
const log = createLogger(
|
||||
p
|
||||
.split("-")
|
||||
.map((e) => e[0])
|
||||
.join(""),
|
||||
);
|
||||
log`Build started`;
|
||||
await build({
|
||||
compile: true,
|
||||
minify: true,
|
||||
target: `bun-${p}` as "bun",
|
||||
entrypoints: ["index.ts"],
|
||||
outdir: `dist/${p}`,
|
||||
features: ["prod"],
|
||||
bytecode: true,
|
||||
});
|
||||
log`Build finished`;
|
||||
const output = await readdir(`dist/${p}`).then((e) => e[0]!);
|
||||
const [_, ext] = output.split(".");
|
||||
await rename(
|
||||
`dist/${p}/${output}`,
|
||||
`dist/kuber${p.includes("arm64") ? "-arm64" : ""}${ext ? `.${ext}` : ""}`,
|
||||
);
|
||||
await rmdir(`dist/${p}`);
|
||||
}
|
||||
|
||||
const PLATFORMS = ["linux-x64", "linux-arm64", "windows-x64"];
|
||||
|
||||
await Promise.all(PLATFORMS.map(platform));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineCommand } from "citty";
|
||||
import { Listr } from "listr2";
|
||||
import { ctx } from "../lib/context";
|
||||
import {
|
||||
assertManagedNamespace,
|
||||
deleteResource,
|
||||
listManagedResources,
|
||||
sortResources,
|
||||
@@ -21,6 +22,7 @@ export const down = defineCommand({
|
||||
},
|
||||
async run({ args }) {
|
||||
const { project } = ctx();
|
||||
await assertManagedNamespace(project);
|
||||
const resources = sortResources(await listManagedResources(project)).filter(
|
||||
(resource) =>
|
||||
args.full ||
|
||||
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { Listr } from "listr2";
|
||||
import { listManagedDeployments, scaleDeployment } from "../lib/shared";
|
||||
import { assertManagedNamespace } from "../lib/apply";
|
||||
import { getProject, listManagedDeployments, scaleDeployment } from "../lib/shared";
|
||||
|
||||
export const stop = defineCommand({
|
||||
meta: {
|
||||
@@ -8,6 +9,7 @@ export const stop = defineCommand({
|
||||
description: "Scale managed deployments to zero",
|
||||
},
|
||||
async run() {
|
||||
await assertManagedNamespace(getProject());
|
||||
const deployments = await listManagedDeployments();
|
||||
|
||||
await new Listr([
|
||||
|
||||
+11
-2
@@ -3,6 +3,7 @@ import { Listr } from "listr2";
|
||||
import type { ComposeSpecification } from "../schema/docker.d";
|
||||
import { ctx } from "../lib/context";
|
||||
import {
|
||||
assertManagedNamespace,
|
||||
applyResource,
|
||||
deleteResource,
|
||||
getStaleResources,
|
||||
@@ -19,6 +20,7 @@ type UpContext = {
|
||||
|
||||
export async function runUp(build: boolean) {
|
||||
const { project, compose, cwd } = ctx();
|
||||
await assertManagedNamespace(project);
|
||||
|
||||
await new Listr<UpContext>([
|
||||
{
|
||||
@@ -30,14 +32,21 @@ export async function runUp(build: boolean) {
|
||||
},
|
||||
{
|
||||
title: "Build images",
|
||||
rendererOptions: {
|
||||
outputBar: 10,
|
||||
persistentOutput: true,
|
||||
},
|
||||
enabled: () => build,
|
||||
skip: (taskCtx) =>
|
||||
Object.values(taskCtx.compose?.services ?? {}).some((service) => service.build)
|
||||
? false
|
||||
: "No buildable services",
|
||||
task: async (taskCtx, task) => {
|
||||
const built = await buildServices(project, taskCtx.compose!, cwd, (message) => {
|
||||
task.output = message;
|
||||
const built = await buildServices(project, taskCtx.compose!, cwd, {
|
||||
progress: (message) => {
|
||||
task.output = message;
|
||||
},
|
||||
stream: task.stdout(),
|
||||
});
|
||||
task.output = `Built ${built} image${built === 1 ? "" : "s"}`;
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ const ManagedResources = [
|
||||
] as const;
|
||||
|
||||
const ManagedBySelector = `app.kubernetes.io/managed-by=${LABELS["app.kubernetes.io/managed-by"]}`;
|
||||
const ManagedByLabel = LABELS["app.kubernetes.io/managed-by"];
|
||||
|
||||
function getResourceOrder(resource: KubernetesObject): number {
|
||||
return ResourceOrder[resource.kind as keyof typeof ResourceOrder] ?? 999;
|
||||
@@ -38,6 +39,42 @@ export function getResourceKey(resource: KubernetesObject): string {
|
||||
return `${resource.kind}:${resource.metadata?.namespace ?? ""}:${resource.metadata?.name ?? ""}`;
|
||||
}
|
||||
|
||||
export async function getNamespaceManagementStatus(
|
||||
namespace: string,
|
||||
): Promise<"missing" | "managed" | "external"> {
|
||||
try {
|
||||
const existing = await objectApi.read({
|
||||
apiVersion: "v1",
|
||||
kind: "Namespace",
|
||||
metadata: { name: namespace },
|
||||
});
|
||||
|
||||
return existing.metadata?.labels?.["app.kubernetes.io/managed-by"] === ManagedByLabel
|
||||
? "managed"
|
||||
: "external";
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === 404
|
||||
) {
|
||||
return "missing";
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertManagedNamespace(namespace: string): Promise<void> {
|
||||
const status = await getNamespaceManagementStatus(namespace);
|
||||
if (status === "external") {
|
||||
throw new Error(
|
||||
`Namespace ${namespace} is not managed by kuber. Refusing to mutate it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyResource<T extends KubernetesObject>(
|
||||
resource: T,
|
||||
): Promise<T> {
|
||||
|
||||
+113
-33
@@ -1,6 +1,7 @@
|
||||
import { basename, isAbsolute, relative, resolve } from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { Writable } from "node:stream";
|
||||
import type { ComposeSpecification, Service } from "../schema/docker.d";
|
||||
import { IMAGE_REGISTRY } from "../const";
|
||||
|
||||
@@ -17,6 +18,22 @@ type BuildPlan = {
|
||||
};
|
||||
|
||||
type ProgressReporter = (message: string) => void | Promise<void>;
|
||||
type BuildReporter = {
|
||||
progress?: ProgressReporter;
|
||||
stream?: Writable;
|
||||
};
|
||||
|
||||
type SpawnResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
function toReadableStream(
|
||||
stream: number | ReadableStream<Uint8Array> | undefined,
|
||||
): ReadableStream<Uint8Array> | undefined {
|
||||
return typeof stream === "number" ? undefined : stream;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
@@ -83,28 +100,87 @@ function resolveBuildPlan(
|
||||
};
|
||||
}
|
||||
|
||||
async function runWithOutput(
|
||||
command: ReturnType<typeof Bun.$>,
|
||||
report?: ProgressReporter,
|
||||
async function pumpStream(
|
||||
stream: ReadableStream<Uint8Array> | null | undefined,
|
||||
onLine: (line: string) => void | Promise<void>,
|
||||
) {
|
||||
if (!report) {
|
||||
await command;
|
||||
return;
|
||||
if (!stream) return;
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) await onLine(line);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
const line = buffer.replace(/\r$/, "");
|
||||
if (line) await onLine(line);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithOutput(
|
||||
command: Bun.Subprocess,
|
||||
reporter?: BuildReporter,
|
||||
) {
|
||||
const stdoutLines: string[] = [];
|
||||
const stderrLines: string[] = [];
|
||||
|
||||
await Promise.all([
|
||||
pumpStream(toReadableStream(command.stdout), async (line) => {
|
||||
stdoutLines.push(line);
|
||||
if (reporter?.stream) reporter.stream.write(`${line}\n`);
|
||||
else if (reporter?.progress) await reporter.progress(line);
|
||||
}),
|
||||
pumpStream(toReadableStream(command.stderr), async (line) => {
|
||||
stderrLines.push(line);
|
||||
if (reporter?.stream) reporter.stream.write(`${line}\n`);
|
||||
else if (reporter?.progress) await reporter.progress(line);
|
||||
}),
|
||||
]);
|
||||
|
||||
const exitCode = await command.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
stderrLines.join("\n") || stdoutLines.join("\n") || `Command failed with exit code ${exitCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
for await (const line of command.lines()) {
|
||||
const text = line.trim();
|
||||
if (text) await report(text);
|
||||
}
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdoutLines.join("\n"),
|
||||
stderr: stderrLines.join("\n"),
|
||||
} satisfies SpawnResult;
|
||||
}
|
||||
|
||||
function ssh(script: string) {
|
||||
const remoteCommand = `bash -lc ${shellQuote(script)}`;
|
||||
return Bun.$`sh -lc ${`ssh ${shellQuote(REMOTE_BUILDER)} ${shellQuote(remoteCommand)} 2>&1`}`;
|
||||
return Bun.spawn(["ssh", REMOTE_BUILDER, remoteCommand], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
function scp(localPath: string, remotePath: string) {
|
||||
return Bun.$`sh -lc ${`scp ${shellQuote(localPath)} ${shellQuote(`${REMOTE_BUILDER}:${remotePath}`)} 2>&1`}`;
|
||||
return Bun.spawn(["scp", localPath, `${REMOTE_BUILDER}:${remotePath}`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
async function getRepoRoot(cwd: string): Promise<string> {
|
||||
@@ -120,20 +196,24 @@ async function getTrackedDiff(repoRoot: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function getRemoteHead(remoteRepo: string): Promise<string | undefined> {
|
||||
const result = await ssh(
|
||||
`if [ -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} rev-parse HEAD; fi`,
|
||||
).nothrow();
|
||||
const result = Bun.spawn(
|
||||
[
|
||||
"ssh",
|
||||
REMOTE_BUILDER,
|
||||
`bash -lc ${shellQuote(`if [ -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} rev-parse HEAD; fi`)}`,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) return;
|
||||
const text = await result.text();
|
||||
const head = text.trim();
|
||||
const output = await runWithOutput(result);
|
||||
const head = output.stdout.trim();
|
||||
return head || undefined;
|
||||
}
|
||||
|
||||
async function syncRemoteRepo(
|
||||
repoRoot: string,
|
||||
remoteRepo: string,
|
||||
report?: ProgressReporter,
|
||||
reporter?: BuildReporter,
|
||||
) {
|
||||
const headSha = await getHeadSha(repoRoot);
|
||||
const remoteHead = await getRemoteHead(remoteRepo);
|
||||
@@ -141,13 +221,13 @@ async function syncRemoteRepo(
|
||||
|
||||
try {
|
||||
if (remoteHead !== headSha) {
|
||||
await report?.("Transferring latest git bundle");
|
||||
await reporter?.progress?.("Transferring latest git bundle");
|
||||
const bundlePath = `${tempDir}/repo.bundle`;
|
||||
const remoteBundle = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}.bundle`;
|
||||
|
||||
await Bun.$.cwd(repoRoot)`git bundle create ${bundlePath} HEAD`;
|
||||
await runWithOutput(ssh(`mkdir -p ${shellQuote(REMOTE_BUILD_ROOT)}`), report);
|
||||
await runWithOutput(scp(bundlePath, remoteBundle), report);
|
||||
await runWithOutput(ssh(`mkdir -p ${shellQuote(REMOTE_BUILD_ROOT)}`), reporter);
|
||||
await runWithOutput(scp(bundlePath, remoteBundle), reporter);
|
||||
await runWithOutput(ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
@@ -157,7 +237,7 @@ async function syncRemoteRepo(
|
||||
`git -C ${shellQuote(remoteRepo)} reset --hard FETCH_HEAD`,
|
||||
`git -C ${shellQuote(remoteRepo)} clean -fd`,
|
||||
].join("; "),
|
||||
), report);
|
||||
), reporter);
|
||||
} else {
|
||||
await runWithOutput(ssh(
|
||||
[
|
||||
@@ -165,23 +245,23 @@ async function syncRemoteRepo(
|
||||
`git -C ${shellQuote(remoteRepo)} reset --hard HEAD`,
|
||||
`git -C ${shellQuote(remoteRepo)} clean -fd`,
|
||||
].join("; "),
|
||||
), report);
|
||||
), reporter);
|
||||
}
|
||||
|
||||
const diff = await getTrackedDiff(repoRoot);
|
||||
if (diff.trim().length === 0) return;
|
||||
|
||||
await report?.("Applying local git diff on remote builder");
|
||||
await reporter?.progress?.("Applying local git diff on remote builder");
|
||||
const diffPath = `${tempDir}/repo.diff`;
|
||||
const remoteDiff = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}.diff`;
|
||||
await Bun.write(diffPath, diff);
|
||||
await runWithOutput(scp(diffPath, remoteDiff), report);
|
||||
await runWithOutput(scp(diffPath, remoteDiff), reporter);
|
||||
await runWithOutput(ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`git -C ${shellQuote(remoteRepo)} apply --allow-binary-replacement "$PWD/${remoteDiff}"`,
|
||||
].join("; "),
|
||||
), report);
|
||||
), reporter);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -200,8 +280,8 @@ function getBuildPlans(
|
||||
});
|
||||
}
|
||||
|
||||
async function buildRemote(plan: BuildPlan, report?: ProgressReporter) {
|
||||
await report?.(`Building ${plan.name}`);
|
||||
async function buildRemote(plan: BuildPlan, reporter?: BuildReporter) {
|
||||
await reporter?.progress?.(`Building ${plan.name}`);
|
||||
const args = [
|
||||
"docker",
|
||||
"build",
|
||||
@@ -216,14 +296,14 @@ async function buildRemote(plan: BuildPlan, report?: ProgressReporter) {
|
||||
];
|
||||
|
||||
const command = args.map(shellQuote).join(" ");
|
||||
await runWithOutput(ssh(`set -euo pipefail; ${command}`), report);
|
||||
await runWithOutput(ssh(`set -euo pipefail; ${command}`), reporter);
|
||||
}
|
||||
|
||||
export async function buildServices(
|
||||
project: string,
|
||||
compose: ComposeSpecification,
|
||||
cwd = process.cwd(),
|
||||
report?: ProgressReporter,
|
||||
reporter?: BuildReporter,
|
||||
): Promise<number> {
|
||||
const repoRoot = await getRepoRoot(cwd);
|
||||
const remoteRepo = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
|
||||
@@ -231,10 +311,10 @@ export async function buildServices(
|
||||
|
||||
if (plans.length === 0) return 0;
|
||||
|
||||
await syncRemoteRepo(repoRoot, remoteRepo, report);
|
||||
await syncRemoteRepo(repoRoot, remoteRepo, reporter);
|
||||
|
||||
for (const plan of plans) {
|
||||
await buildRemote(plan, report);
|
||||
await buildRemote(plan, reporter);
|
||||
}
|
||||
|
||||
return plans.length;
|
||||
|
||||
+2
-3
@@ -2,12 +2,10 @@ import { basename } from "node:path";
|
||||
import type { V1Deployment, V1Pod } from "@kubernetes/client-node";
|
||||
import { LABELS } from "../const";
|
||||
import { apps, core, objectApi } from "./k8s";
|
||||
import { feature } from "bun:bundle";
|
||||
|
||||
const ManagedBySelector = `app.kubernetes.io/managed-by=${LABELS["app.kubernetes.io/managed-by"]}`;
|
||||
|
||||
export function getProject() {
|
||||
if (!feature("prod")) return "kube-system";
|
||||
const cwd = process.cwd();
|
||||
return basename(cwd);
|
||||
}
|
||||
@@ -51,7 +49,8 @@ export async function getPodForDeployment(name: string): Promise<V1Pod> {
|
||||
|
||||
export function getPodContainerName(pod: V1Pod): string {
|
||||
const container = pod.spec?.containers[0]?.name;
|
||||
if (!container) throw new Error(`No container found in pod ${pod.metadata?.name}`);
|
||||
if (!container)
|
||||
throw new Error(`No container found in pod ${pod.metadata?.name}`);
|
||||
return container;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user