feat: add restart command for managed deployments and enhance logging functionality

This commit is contained in:
2026-08-09 14:43:33 +07:00 Unverified
parent 58c26b735a
commit a0abaf05b9
8 changed files with 337 additions and 93 deletions
+138 -68
View File
@@ -1,55 +1,134 @@
import { defineCommand } from "citty";
import { finished } from "node:stream/promises";
import { Writable } from "node:stream";
import https from "node:https";
import { createLogger } from "../lib/logger";
import { core, logClient } from "../lib/k8s";
import { core, kc } from "../lib/k8s";
import {
getPodContainerName,
listManagedDeployments,
listPodsForDeployment,
} from "../lib/shared";
class LoggerStream extends Writable {
#buffer = "";
constructor(private readonly writeLine: (line: string) => void) {
super();
}
override _write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
this.#buffer += chunk.toString();
let newline = this.#buffer.indexOf("\n");
while (newline !== -1) {
const line = this.#buffer.slice(0, newline).replace(/\r$/, "");
if (line) this.writeLine(line);
this.#buffer = this.#buffer.slice(newline + 1);
newline = this.#buffer.indexOf("\n");
}
callback();
}
override _final(callback: (error?: Error | null) => void) {
const line = this.#buffer.replace(/\r$/, "");
if (line) this.writeLine(line);
this.#buffer = "";
callback();
}
}
function formatError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
async function logDeployment(name: string, follow: boolean) {
const pods = await listPodsForDeployment(name);
if (pods.length === 0) throw new Error(`No pods found for deployment ${name}`);
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function streamResponseLines(
stream: NodeJS.ReadableStream,
onLine: (line: string) => void,
) {
let buffer = "";
for await (const chunk of stream) {
buffer += chunk.toString();
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (line) onLine(line);
newline = buffer.indexOf("\n");
}
}
const line = buffer.replace(/\r$/, "");
if (line) onLine(line);
}
async function followPodLogs(
namespace: string,
podName: string,
containerName: string,
writeLine: (line: string) => void,
signal: AbortSignal,
) {
const cluster = kc.getCurrentCluster();
if (!cluster) throw new Error("No currently active cluster");
const requestURL = new URL(`${cluster.server}/api/v1/namespaces/${namespace}/pods/${podName}/log`);
requestURL.searchParams.set("container", containerName);
requestURL.searchParams.set("follow", "true");
const options: https.RequestOptions = {
method: "GET",
protocol: requestURL.protocol,
hostname: requestURL.hostname,
port: requestURL.port,
path: `${requestURL.pathname}${requestURL.search}`,
signal,
};
await kc.applyToHTTPSOptions(options);
await new Promise<void>((resolve, reject) => {
const request = https.request(options, async (response) => {
if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) > 299) {
const chunks: Buffer[] = [];
response.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => {
reject(Buffer.concat(chunks).toString("utf8") || `HTTP ${response.statusCode}`);
});
return;
}
try {
await streamResponseLines(response, writeLine);
resolve();
} catch (error) {
reject(error);
}
});
request.on("error", reject);
request.end();
});
}
async function followDeploymentLogs(name: string, signal: AbortSignal) {
const logger = createLogger(name);
const activePods = new Set<string>();
while (!signal.aborted) {
const pods = await listPodsForDeployment(name);
for (const pod of pods) {
const podName = pod.metadata?.name;
if (!podName || activePods.has(podName) || pod.status?.phase !== "Running") {
continue;
}
activePods.add(podName);
void followPodLogs(
pod.metadata?.namespace ?? "default",
podName,
getPodContainerName(pod),
(line) => logger`${line}`,
signal,
)
.catch((error) => {
if (!signal.aborted) logger.warn`${podName}: ${formatError(error)}`;
})
.finally(() => {
activePods.delete(podName);
});
}
await delay(2000);
}
}
async function logDeployment(name: string, follow: boolean) {
const logger = createLogger(name);
if (!follow) {
const pods = await listPodsForDeployment(name);
if (pods.length === 0) throw new Error(`No pods found for deployment ${name}`);
for (const pod of pods) {
const podName = pod.metadata?.name;
if (!podName) continue;
@@ -71,45 +150,36 @@ async function logDeployment(name: string, follow: boolean) {
return;
}
const controllers = await Promise.all(
pods.map(async (pod) => {
const podName = pod.metadata?.name;
if (!podName) return;
const controller = new AbortController();
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
process.off("SIGINT", abort);
process.off("SIGTERM", abort);
};
const stream = new LoggerStream((line) => logger`${line}`);
try {
const controller = await logClient.log(
pod.metadata?.namespace ?? "default",
podName,
getPodContainerName(pod),
stream,
{ follow: true },
);
return { controller, stream };
} catch (error) {
logger.warn`${podName}: ${formatError(error)}`;
stream.end();
}
}),
);
const activeControllers = controllers.filter(
(item): item is NonNullable<(typeof controllers)[number]> => Boolean(item),
);
if (activeControllers.length === 0) return;
await new Promise<void>((resolve) => {
const abort = () => {
for (const item of activeControllers) item.controller.abort();
controller.abort();
cleanup();
resolve();
};
process.once("SIGINT", abort);
process.once("SIGTERM", abort);
process.on("SIGINT", abort);
process.on("SIGTERM", abort);
void followDeploymentLogs(name, controller.signal)
.then(() => {
cleanup();
resolve();
})
.catch((error) => {
cleanup();
if (controller.signal.aborted) {
resolve();
return;
}
controller.abort();
reject(error);
});
});
await Promise.all(activeControllers.map((item) => finished(item.stream)));
}
export const logs = defineCommand({
+37
View File
@@ -0,0 +1,37 @@
import { defineCommand } from "citty";
import { Listr } from "listr2";
import { assertManagedNamespace } from "../lib/apply";
import {
getProject,
listManagedDeployments,
restartDeployment,
} from "../lib/shared";
export const restart = defineCommand({
meta: {
name: "restart",
description: "Roll out a restart for managed deployments",
},
async run() {
await assertManagedNamespace(getProject());
const deployments = await listManagedDeployments();
await new Listr([
{
title: "Restart deployments",
skip: () =>
deployments.length > 0 ? false : "No managed deployments found",
task: (_ctx, task) => {
task.output = `${deployments.length} deployments queued`;
return task.newListr(
deployments.map((deployment) => ({
title: `Deployment ${deployment.metadata?.name}`,
task: () => restartDeployment(deployment.metadata!.name!),
})),
{ concurrent: false, exitOnError: true },
);
},
},
]).run();
},
});
+2 -1
View File
@@ -3,6 +3,7 @@ import { down } from "./command/down";
import { exec } from "./command/exec";
import { logs } from "./command/logs";
import { ps } from "./command/ps";
import { restart } from "./command/restart";
import { start } from "./command/start";
import { stop } from "./command/stop";
import { up } from "./command/up";
@@ -15,7 +16,7 @@ const main = defineCommand({
version: "1.0.0",
description: "Docker Compose -> K8s translation layer",
},
subCommands: { down, exec, logs, ps, start, stop, up },
subCommands: { down, exec, logs, ps, restart, start, stop, up },
});
provideContext(() => runMain(main)).catch((e) => {
+5 -3
View File
@@ -9,14 +9,16 @@ const ResourceOrder = {
Namespace: 0,
PersistentVolumeClaim: 1,
Secret: 2,
Service: 3,
Deployment: 4,
Ingress: 5,
ConfigMap: 3,
Service: 4,
Deployment: 5,
Ingress: 6,
} as const;
const ManagedResources = [
{ apiVersion: "v1", kind: "PersistentVolumeClaim" },
{ apiVersion: "v1", kind: "Secret" },
{ apiVersion: "v1", kind: "ConfigMap" },
{ apiVersion: "v1", kind: "Service" },
{ apiVersion: "apps/v1", kind: "Deployment" },
{ apiVersion: "networking.k8s.io/v1", kind: "Ingress" },
+42 -8
View File
@@ -1,6 +1,6 @@
import { basename, isAbsolute, relative, resolve } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { hostname, tmpdir } from "node:os";
import type { Writable } from "node:stream";
import type { ComposeSpecification, Service } from "../schema/docker.d";
import { IMAGE_REGISTRY } from "../const";
@@ -57,7 +57,7 @@ function resolveBuildPlan(
service: Service,
cwd: string,
repoRoot: string,
remoteRepo: string,
buildRoot: string,
): BuildPlan | undefined {
if (!service.build) return;
@@ -93,13 +93,17 @@ function resolveBuildPlan(
return {
name,
image: `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`,
context: `${remoteRepo}/${contextRelative === "" ? "." : contextRelative}`,
dockerfile: dockerfileRelative ? `${remoteRepo}/${dockerfileRelative}` : undefined,
context: `${buildRoot}/${contextRelative === "" ? "." : contextRelative}`,
dockerfile: dockerfileRelative ? `${buildRoot}/${dockerfileRelative}` : undefined,
target: typeof build === "string" ? undefined : build.target,
buildArgs: resolveBuildArgs(service),
};
}
function isOnRemoteBuilder(): boolean {
return hostname() === REMOTE_BUILDER.split("@").at(-1);
}
async function pumpStream(
stream: ReadableStream<Uint8Array> | null | undefined,
onLine: (line: string) => void | Promise<void>,
@@ -299,6 +303,30 @@ async function buildRemote(plan: BuildPlan, reporter?: BuildReporter) {
await runWithOutput(ssh(`set -euo pipefail; ${command}`), reporter);
}
async function buildLocal(plan: BuildPlan, reporter?: BuildReporter) {
await reporter?.progress?.(`Building ${plan.name}`);
const command = Bun.spawn(
[
"docker",
"build",
"--push",
"--progress=plain",
"-t",
plan.image,
...(plan.dockerfile ? ["-f", plan.dockerfile] : []),
...(plan.target ? ["--target", plan.target] : []),
...plan.buildArgs.flatMap((arg) => ["--build-arg", arg]),
plan.context,
],
{
stdout: "pipe",
stderr: "pipe",
},
);
await runWithOutput(command, reporter);
}
export async function buildServices(
project: string,
compose: ComposeSpecification,
@@ -306,15 +334,21 @@ export async function buildServices(
reporter?: BuildReporter,
): Promise<number> {
const repoRoot = await getRepoRoot(cwd);
const remoteRepo = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
const plans = getBuildPlans(project, compose, cwd, repoRoot, remoteRepo);
const localBuilder = isOnRemoteBuilder();
const buildRoot = localBuilder
? repoRoot
: `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
if (plans.length === 0) return 0;
await syncRemoteRepo(repoRoot, remoteRepo, reporter);
if (!localBuilder) {
await syncRemoteRepo(repoRoot, buildRoot, reporter);
}
for (const plan of plans) {
await buildRemote(plan, reporter);
if (localBuilder) await buildLocal(plan, reporter);
else await buildRemote(plan, reporter);
}
return plans.length;
+90 -13
View File
@@ -13,8 +13,9 @@ import type {
V1Volume,
V1VolumeMount,
} from "@kubernetes/client-node";
import { existsSync, readFileSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { basename, resolve } from "node:path";
import type { ComposeSpecification } from "../schema/docker.d";
import type { Service } from "../schema/docker.d";
import { IMAGE_REGISTRY, LABELS } from "../const";
@@ -23,10 +24,11 @@ import { deepMerge } from "./shared";
type NormalizedMount = {
name: string;
kind: "bind" | "volume" | "tmpfs";
kind: "bind" | "config-file" | "volume" | "tmpfs";
target: string;
source?: string;
readOnly?: boolean;
configKey?: string;
subPath?: string;
sizeLimit?: string;
};
@@ -58,6 +60,15 @@ function toReplicaCount(service: Service): number {
);
}
function toDeploymentImage(
project: string,
name: string,
service: Service,
): string | undefined {
if (service.build) return `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`;
return service.image;
}
function toProtocol(value: string | undefined): "TCP" | "UDP" {
return value?.toUpperCase() === "UDP" ? "UDP" : "TCP";
}
@@ -108,9 +119,21 @@ function isBindSource(source: string): boolean {
);
}
function resolveSourcePath(source: string, cwd: string): string {
return source.startsWith("~")
? resolve(process.env.HOME ?? "", source.slice(1))
: resolve(cwd, source);
}
function isConfigFileSource(source: string, cwd: string): boolean {
const path = resolveSourcePath(source, cwd);
return existsSync(path) && statSync(path).isFile();
}
function parseStringMount(
entry: string,
index: number,
cwd: string,
): NormalizedMount | undefined {
const parts = entry.split(":");
@@ -134,9 +157,15 @@ function parseStringMount(
source && isBindSource(source)
? toBindVolumeName(source)
: `volume-${index}`,
kind: source && isBindSource(source) ? "bind" : "volume",
kind:
source && isBindSource(source)
? isConfigFileSource(source, cwd)
? "config-file"
: "bind"
: "volume",
source: source || undefined,
target,
configKey: source ? basename(source) : undefined,
readOnly: hasMode ? maybeMode === "ro" : undefined,
};
}
@@ -144,8 +173,9 @@ function parseStringMount(
function toMount(
entry: ServiceVolume,
index: number,
cwd: string,
): NormalizedMount | undefined {
if (typeof entry === "string") return parseStringMount(entry, index);
if (typeof entry === "string") return parseStringMount(entry, index, cwd);
if (!entry.target) return;
if (
@@ -162,10 +192,14 @@ function toMount(
? toBindVolumeName(entry.source)
: `${entry.type}-${toKubeName(entry.source) || index}`
: `${entry.type}-${index}`,
kind: entry.type,
kind:
entry.type === "bind" && entry.source && isConfigFileSource(entry.source, cwd)
? "config-file"
: entry.type,
source: entry.source,
target: entry.target,
readOnly: toBoolean(entry.read_only),
configKey: entry.source ? basename(entry.source) : undefined,
subPath: entry.volume?.subpath,
sizeLimit:
entry.type === "tmpfs" && entry.tmpfs?.size !== undefined
@@ -174,10 +208,10 @@ function toMount(
};
}
function toMounts(service: Service): NormalizedMount[] {
function toMounts(service: Service, cwd = process.cwd()): NormalizedMount[] {
const mounts =
service.volumes?.flatMap((entry, index) => {
const mount = toMount(entry, index);
const mount = toMount(entry, index, cwd);
return mount ? [mount] : [];
}) ?? [];
@@ -201,7 +235,7 @@ function toVolumeMounts(
name: mount.name,
mountPath: mount.target,
readOnly: mount.readOnly,
subPath: mount.subPath,
subPath: mount.kind === "config-file" ? mount.configKey : mount.subPath,
}));
}
@@ -212,6 +246,15 @@ function toVolumes(
if (mounts.length === 0) return;
return mounts.map((mount) => {
if (mount.kind === "config-file") {
return {
name: mount.name,
configMap: {
name: mount.name,
},
};
}
if (mount.kind === "bind") {
if (!mount.source) {
return {
@@ -433,8 +476,9 @@ export function serviceToDeployment(
project: string,
name: string,
service: Service,
cwd = process.cwd(),
): V1Deployment {
const mounts = toMounts(service);
const mounts = toMounts(service, cwd);
const ports = toPorts(service);
return deepMerge(
@@ -472,7 +516,7 @@ export function serviceToDeployment(
name,
imagePullPolicy: "Always",
env: toEnvVars(service.environment),
image: `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`,
image: toDeploymentImage(project, name, service),
command:
service.command instanceof Array
? service.command
@@ -543,10 +587,11 @@ export function serviceToIngress(
export function volumesToPvc(
project: string,
service: Service,
cwd = process.cwd(),
): V1PersistentVolumeClaim[] {
const claims = new Map<string, V1PersistentVolumeClaim>();
for (const mount of toMounts(service)) {
for (const mount of toMounts(service, cwd)) {
const claimName =
mount.kind === "bind"
? mount.source
@@ -580,6 +625,34 @@ export function volumesToPvc(
return [...claims.values()];
}
export function volumesToConfigMaps(
project: string,
service: Service,
cwd = process.cwd(),
): V1ConfigMap[] {
const configMaps = new Map<string, V1ConfigMap>();
for (const mount of toMounts(service, cwd)) {
if (mount.kind !== "config-file" || !mount.source || !mount.configKey) continue;
const sourcePath = resolveSourcePath(mount.source, cwd);
configMaps.set(mount.name, {
apiVersion: "v1",
kind: "ConfigMap",
metadata: {
name: mount.name,
namespace: project,
labels: LABELS,
},
data: {
[mount.configKey]: readFileSync(sourcePath, "utf8"),
},
});
}
return [...configMaps.values()];
}
export async function envFromToSecrets(
project: string,
name: string,
@@ -640,10 +713,14 @@ export async function composeToKubernetes(
resources.set(getResourceKey(namespace), namespace);
for (const [name, service] of Object.entries(compose.services ?? {})) {
for (const pvc of volumesToPvc(project, service)) {
for (const pvc of volumesToPvc(project, service, cwd)) {
resources.set(getResourceKey(pvc), pvc);
}
for (const configMap of volumesToConfigMaps(project, service, cwd)) {
resources.set(getResourceKey(configMap), configMap);
}
for (const secret of await envFromToSecrets(project, name, service, cwd)) {
resources.set(getResourceKey(secret), secret);
}
@@ -651,7 +728,7 @@ export async function composeToKubernetes(
const svc = serviceToSvc(project, name, service);
if (svc) resources.set(getResourceKey(svc), svc);
const deployment = serviceToDeployment(project, name, service);
const deployment = serviceToDeployment(project, name, service, cwd);
resources.set(getResourceKey(deployment), deployment);
const ingress = serviceToIngress(project, name, service);
+20
View File
@@ -68,6 +68,26 @@ export async function scaleDeployment(name: string, replicas: number) {
});
}
export async function restartDeployment(name: string) {
return objectApi.patch({
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {
name,
namespace: getProject(),
},
spec: {
template: {
metadata: {
annotations: {
"kubectl.kubernetes.io/restartedAt": new Date().toISOString(),
},
},
},
},
});
}
export function throwWhen<T, Narrowed extends T>(
condition: (arg: T) => arg is Narrowed,
): (arg: T) => Exclude<T, Narrowed>;
+3
View File
@@ -18,5 +18,8 @@
"citty": "^0.2.2",
"listr2": "^11.0.0",
"zod": "^4.4.3"
},
"scripts": {
"prod": "bun run build && cp dist/kuber ~/.bun/bin/ && scp dist/kuber-arm64 root@astral:/usr/bin/kuber"
}
}