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
+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>;