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:
+241
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user