feat: add restart command for managed deployments and enhance logging functionality
This commit is contained in:
+5
-3
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user