fix: restart after reconcile

This commit is contained in:
2026-08-15 18:36:56 +07:00 Unverified
parent ed7ee7a113
commit 3f70382fd3
3 changed files with 179 additions and 9 deletions
+38
View File
@@ -11,6 +11,7 @@ import {
} from "../lib/apply";
import { buildServices } from "../lib/build";
import { composeToKubernetes, type KubernetesResource } from "../lib/convert";
import { restartDeployment, waitForDeploymentRollout } from "../lib/shared";
type UpContext = {
compose?: ComposeSpecification;
@@ -18,6 +19,13 @@ type UpContext = {
staleResources?: KubernetesResource[];
};
function getDeploymentNames(resources: KubernetesResource[]): string[] {
return resources
.filter((resource) => resource.kind === "Deployment")
.map((resource) => resource.metadata?.name)
.filter((name): name is string => Boolean(name));
}
export async function runUp(build: boolean) {
const { project, compose, cwd } = ctx();
await assertManagedNamespace(project);
@@ -83,6 +91,36 @@ export async function runUp(build: boolean) {
);
},
},
{
title: "Restart deployments",
task: (taskCtx, task) => {
const deployments = getDeploymentNames(taskCtx.resources!);
task.output = `${deployments.length} deployments queued`;
return task.newListr(
deployments.map((name) => ({
title: `Deployment ${name}`,
task: () => restartDeployment(name),
})),
{ concurrent: false, exitOnError: true },
);
},
},
{
title: "Wait for rollout",
task: (taskCtx, task) => {
const deployments = getDeploymentNames(taskCtx.resources!);
task.output = `${deployments.length} deployments queued`;
return task.newListr(
deployments.map((name) => ({
title: `Deployment ${name}`,
task: () => waitForDeploymentRollout(name),
})),
{ concurrent: false, exitOnError: true },
);
},
},
{
title: "Delete stale resources",
skip: (taskCtx) =>
+107 -9
View File
@@ -1,5 +1,5 @@
import { basename, isAbsolute, relative, resolve } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
import { hostname, tmpdir } from "node:os";
import type { Writable } from "node:stream";
import type { ComposeSpecification, Service } from "../schema/docker.d";
@@ -199,6 +199,49 @@ async function getTrackedDiff(repoRoot: string): Promise<string> {
return Bun.$.cwd(repoRoot)`git diff --binary HEAD`.text();
}
async function getUntrackedFiles(repoRoot: string): Promise<string[]> {
const result = Bun.spawn(
["git", "ls-files", "--others", "--exclude-standard"],
{
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
},
);
const output = await runWithOutput(result);
return output.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function getIgnoredDotenvFiles(repoRoot: string): Promise<string[]> {
const result = Bun.spawn(
[
"git",
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"--",
".env*",
"**/.env*",
],
{
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
},
);
const output = await runWithOutput(result);
return output.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function getRemoteHead(remoteRepo: string): Promise<string | undefined> {
const result = Bun.spawn(
[
@@ -253,17 +296,72 @@ async function syncRemoteRepo(
}
const diff = await getTrackedDiff(repoRoot);
if (diff.trim().length === 0) return;
if (diff.trim().length > 0) {
await reporter?.progress?.("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), reporter);
await runWithOutput(ssh(
[
"set -euo pipefail",
`git -C ${shellQuote(remoteRepo)} apply --allow-binary-replacement "$PWD/${remoteDiff}"`,
].join("; "),
), reporter);
}
await reporter?.progress?.("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), reporter);
const untrackedFiles = await getUntrackedFiles(repoRoot);
if (untrackedFiles.length > 0) {
await reporter?.progress?.("Syncing untracked files to remote builder");
const untrackedDir = `${tempDir}/untracked`;
for (const file of untrackedFiles) {
const source = resolve(repoRoot, file);
const target = resolve(untrackedDir, file);
await mkdir(dirname(target), { recursive: true });
await cp(source, target, { force: true, recursive: true });
}
const untrackedArchive = `${tempDir}/untracked.tar`;
const remoteUntrackedArchive = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}-untracked.tar`;
await Bun.$`tar -C ${untrackedDir} -cf ${untrackedArchive} .`;
await runWithOutput(scp(untrackedArchive, remoteUntrackedArchive), reporter);
await runWithOutput(ssh(
[
"set -euo pipefail",
`tar -C ${shellQuote(remoteRepo)} -xf "$PWD/${remoteUntrackedArchive}"`,
].join("; "),
), reporter);
}
const ignoredDotenvFiles = await getIgnoredDotenvFiles(repoRoot);
await runWithOutput(ssh(
[
"set -euo pipefail",
`git -C ${shellQuote(remoteRepo)} apply --allow-binary-replacement "$PWD/${remoteDiff}"`,
`git -C ${shellQuote(remoteRepo)} clean -fdX -- .env* '**/.env*'`,
].join("; "),
), reporter);
if (ignoredDotenvFiles.length === 0) return;
await reporter?.progress?.("Syncing ignored .env* files to remote builder");
const dotenvDir = `${tempDir}/dotenv`;
for (const file of ignoredDotenvFiles) {
const source = resolve(repoRoot, file);
const target = resolve(dotenvDir, file);
await mkdir(dirname(target), { recursive: true });
await cp(source, target, { force: true });
}
const dotenvArchive = `${tempDir}/dotenv.tar`;
const remoteDotenvArchive = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}-dotenv.tar`;
await Bun.$`tar -C ${dotenvDir} -cf ${dotenvArchive} .`;
await runWithOutput(scp(dotenvArchive, remoteDotenvArchive), reporter);
await runWithOutput(ssh(
[
"set -euo pipefail",
`tar -C ${shellQuote(remoteRepo)} -xf "$PWD/${remoteDotenvArchive}"`,
].join("; "),
), reporter);
} finally {
+34
View File
@@ -88,6 +88,40 @@ export async function restartDeployment(name: string) {
});
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitForDeploymentRollout(
name: string,
timeoutMs = 300000,
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const deployment = await getDeployment(name);
const desiredReplicas = deployment.spec?.replicas ?? 1;
const observedGeneration = deployment.status?.observedGeneration ?? 0;
const generation = deployment.metadata?.generation ?? 0;
const updatedReplicas = deployment.status?.updatedReplicas ?? 0;
const availableReplicas = deployment.status?.availableReplicas ?? 0;
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
if (
observedGeneration >= generation &&
updatedReplicas === desiredReplicas &&
availableReplicas === desiredReplicas &&
unavailableReplicas === 0
) {
return;
}
await delay(2000);
}
throw new Error(`Timed out waiting for deployment ${name} rollout`);
}
export function throwWhen<T, Narrowed extends T>(
condition: (arg: T) => arg is Narrowed,
): (arg: T) => Exclude<T, Narrowed>;