feat: add commands for managing deployments and resources

- Implemented `down` command to delete managed resources, with an option to delete ingress and PVCs.
- Added `exec` command to execute commands inside deployment pods.
- Created `logs` command to show deployment logs with an option to follow the output.
- Introduced `start` command to start deployments without rebuilding images.
- Developed `stop` command to scale managed deployments to zero.

refactor: enhance resource management and Kubernetes interactions

- Refactored resource handling in `lib/apply.ts` to support sorting and applying resources.
- Improved logging functionality with a new logger in `lib/logger.ts`.
- Enhanced build process in `lib/build.ts` to support remote builds and syncing with Git repositories.
- Added functions to manage deployments and pods in `lib/shared.ts`.

chore: update dependencies and TypeScript configuration

- Updated package.json to include `listr2` for task management.
- Modified tsconfig.json to exclude build artifacts and fixtures.
This commit is contained in:
2026-08-09 00:18:14 +07:00 Unverified
parent c08fb36d2e
commit 8f331d4cc7
17 changed files with 1299 additions and 50 deletions
+241
View File
@@ -0,0 +1,241 @@
import { basename, isAbsolute, relative, resolve } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import type { ComposeSpecification, Service } from "../schema/docker.d";
import { IMAGE_REGISTRY } from "../const";
const REMOTE_BUILDER = "kuber@astral";
const REMOTE_BUILD_ROOT = "kuber-build";
type BuildPlan = {
name: string;
image: string;
context: string;
dockerfile?: string;
target?: string;
buildArgs: string[];
};
type ProgressReporter = (message: string) => void | Promise<void>;
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function resolveBuildArgs(service: Service): string[] {
if (!service.build || typeof service.build === "string" || !service.build.args) {
return [];
}
if (Array.isArray(service.build.args)) return [...service.build.args];
return Object.entries(service.build.args)
.filter(([, value]) => value !== null)
.map(([key, value]) => `${key}=${String(value)}`);
}
function resolveBuildPlan(
project: string,
name: string,
service: Service,
cwd: string,
repoRoot: string,
remoteRepo: string,
): BuildPlan | undefined {
if (!service.build) return;
const build = service.build;
const contextInput = typeof build === "string" ? build : build.context ?? ".";
if (contextInput.includes("://")) {
throw new Error(`Remote build context is not supported for service ${name}`);
}
const contextPath = resolve(cwd, contextInput);
const contextRelative = relative(repoRoot, contextPath);
if (contextRelative.startsWith("..") || isAbsolute(contextRelative)) {
throw new Error(`Build context must stay inside the git repo for service ${name}`);
}
if (typeof build !== "string" && build.dockerfile_inline) {
throw new Error(`dockerfile_inline is not supported for service ${name}`);
}
const dockerfilePath =
typeof build === "string" || !build.dockerfile
? undefined
: resolve(contextPath, build.dockerfile);
const dockerfileRelative = dockerfilePath
? relative(repoRoot, dockerfilePath)
: undefined;
if (dockerfileRelative?.startsWith("..") || isAbsolute(dockerfileRelative ?? "")) {
throw new Error(`Dockerfile must stay inside the git repo for service ${name}`);
}
return {
name,
image: `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`,
context: `${remoteRepo}/${contextRelative === "" ? "." : contextRelative}`,
dockerfile: dockerfileRelative ? `${remoteRepo}/${dockerfileRelative}` : undefined,
target: typeof build === "string" ? undefined : build.target,
buildArgs: resolveBuildArgs(service),
};
}
async function runWithOutput(
command: ReturnType<typeof Bun.$>,
report?: ProgressReporter,
) {
if (!report) {
await command;
return;
}
for await (const line of command.lines()) {
const text = line.trim();
if (text) await report(text);
}
}
function ssh(script: string) {
const remoteCommand = `bash -lc ${shellQuote(script)}`;
return Bun.$`sh -lc ${`ssh ${shellQuote(REMOTE_BUILDER)} ${shellQuote(remoteCommand)} 2>&1`}`;
}
function scp(localPath: string, remotePath: string) {
return Bun.$`sh -lc ${`scp ${shellQuote(localPath)} ${shellQuote(`${REMOTE_BUILDER}:${remotePath}`)} 2>&1`}`;
}
async function getRepoRoot(cwd: string): Promise<string> {
return Bun.$.cwd(cwd)`git rev-parse --show-toplevel`.text().then((e) => e.trim());
}
async function getHeadSha(repoRoot: string): Promise<string> {
return Bun.$.cwd(repoRoot)`git rev-parse HEAD`.text().then((e) => e.trim());
}
async function getTrackedDiff(repoRoot: string): Promise<string> {
return Bun.$.cwd(repoRoot)`git diff --binary HEAD`.text();
}
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();
if (result.exitCode !== 0) return;
const text = await result.text();
const head = text.trim();
return head || undefined;
}
async function syncRemoteRepo(
repoRoot: string,
remoteRepo: string,
report?: ProgressReporter,
) {
const headSha = await getHeadSha(repoRoot);
const remoteHead = await getRemoteHead(remoteRepo);
const tempDir = await mkdtemp(`${tmpdir()}/kuber-build-`);
try {
if (remoteHead !== headSha) {
await report?.("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(
[
"set -euo pipefail",
`mkdir -p ${shellQuote(remoteRepo)}`,
`if [ ! -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} init; fi`,
`git -C ${shellQuote(remoteRepo)} fetch --force "$PWD/${remoteBundle}" HEAD`,
`git -C ${shellQuote(remoteRepo)} reset --hard FETCH_HEAD`,
`git -C ${shellQuote(remoteRepo)} clean -fd`,
].join("; "),
), report);
} else {
await runWithOutput(ssh(
[
"set -euo pipefail",
`git -C ${shellQuote(remoteRepo)} reset --hard HEAD`,
`git -C ${shellQuote(remoteRepo)} clean -fd`,
].join("; "),
), report);
}
const diff = await getTrackedDiff(repoRoot);
if (diff.trim().length === 0) return;
await report?.("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(ssh(
[
"set -euo pipefail",
`git -C ${shellQuote(remoteRepo)} apply --allow-binary-replacement "$PWD/${remoteDiff}"`,
].join("; "),
), report);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
function getBuildPlans(
project: string,
compose: ComposeSpecification,
cwd: string,
repoRoot: string,
remoteRepo: string,
): BuildPlan[] {
return Object.entries(compose.services ?? {}).flatMap(([name, service]) => {
const plan = resolveBuildPlan(project, name, service, cwd, repoRoot, remoteRepo);
return plan ? [plan] : [];
});
}
async function buildRemote(plan: BuildPlan, report?: ProgressReporter) {
await report?.(`Building ${plan.name}`);
const args = [
"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,
];
const command = args.map(shellQuote).join(" ");
await runWithOutput(ssh(`set -euo pipefail; ${command}`), report);
}
export async function buildServices(
project: string,
compose: ComposeSpecification,
cwd = process.cwd(),
report?: ProgressReporter,
): Promise<number> {
const repoRoot = await getRepoRoot(cwd);
const remoteRepo = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
const plans = getBuildPlans(project, compose, cwd, repoRoot, remoteRepo);
if (plans.length === 0) return 0;
await syncRemoteRepo(repoRoot, remoteRepo, report);
for (const plan of plans) {
await buildRemote(plan, report);
}
return plans.length;
}