feat: add database reconciler

This commit is contained in:
2026-08-16 09:35:34 +07:00 Unverified
parent 3f70382fd3
commit a244f49155
6 changed files with 659 additions and 105 deletions
+83
View File
@@ -0,0 +1,83 @@
import { defineCommand } from "citty";
import { ctx } from "../lib/context";
import {
DATABASE_HOST,
DATABASE_PORT,
buildDatabaseUrl,
getComposePostgresClaims,
getRoleCredentials,
} from "../lib/database";
import { toTable } from "../lib/format";
const list = defineCommand({
meta: {
name: "ls",
description: "List managed postgres claims",
},
async run() {
const claims = getComposePostgresClaims(await ctx().compose());
if (claims.length === 0) {
console.log("No managed postgres volumes declared");
return;
}
console.log(
toTable(
claims.map((claim) => ({
service: claim.service,
username: claim.username,
database: claim.database,
secret: claim.secretName,
})),
),
);
},
});
const creds = defineCommand({
meta: {
name: "creds",
description: "Print postgres credentials for a service",
},
async run({ args }) {
const service = args._[0];
if (!service) throw new Error("Service name is required");
const claim = getComposePostgresClaims(await ctx().compose()).find(
(entry) => entry.service === service,
);
if (!claim) {
throw new Error(
`Service ${service} does not declare a managed postgres volume.`,
);
}
const credentials = await getRoleCredentials(claim.username);
const url = buildDatabaseUrl(claim, credentials);
console.log(
toTable([
{
service: claim.service,
username: credentials.username,
database: claim.database,
host: DATABASE_HOST,
port: DATABASE_PORT,
secret: claim.secretName,
},
]),
);
console.log(`DATABASE_URL=${url}`);
},
});
export const db = defineCommand({
meta: {
name: "db",
description: "Inspect managed postgres databases",
},
subCommands: {
creds,
ls: list,
},
});
+5
View File
@@ -7,6 +7,7 @@ import {
listManagedResources,
sortResources,
} from "../lib/apply";
import { listManagedDatabaseResources } from "../lib/database";
export const down = defineCommand({
meta: {
@@ -30,6 +31,10 @@ export const down = defineCommand({
resource.kind !== "PersistentVolumeClaim"),
);
if (args.full) {
resources.push(...(await listManagedDatabaseResources(project)));
}
if (args.full) {
resources.push({
apiVersion: "v1",
+127 -98
View File
@@ -11,10 +11,15 @@ import {
} from "../lib/apply";
import { buildServices } from "../lib/build";
import { composeToKubernetes, type KubernetesResource } from "../lib/convert";
import {
getComposePostgresClaims,
reconcilePostgresClaims,
} from "../lib/database";
import { restartDeployment, waitForDeploymentRollout } from "../lib/shared";
type UpContext = {
compose?: ComposeSpecification;
serviceEnv?: Record<string, Record<string, string>>;
resources?: KubernetesResource[];
staleResources?: KubernetesResource[];
};
@@ -30,117 +35,141 @@ export async function runUp(build: boolean) {
const { project, compose, cwd } = ctx();
await assertManagedNamespace(project);
await new Listr<UpContext>([
{
title: "Read compose",
task: async (taskCtx, task) => {
taskCtx.compose = await compose();
task.output = `${Object.keys(taskCtx.compose.services ?? {}).length} services`;
await new Listr<UpContext>(
[
{
title: "Read compose",
task: async (taskCtx, task) => {
taskCtx.compose = await compose();
task.output = `${Object.keys(taskCtx.compose.services ?? {}).length} services`;
},
},
},
{
title: "Build images",
rendererOptions: {
outputBar: 10,
persistentOutput: true,
{
title: "Build images",
rendererOptions: {
outputBar: 10,
persistentOutput: true,
},
enabled: () => build,
skip: (taskCtx) =>
Object.values(taskCtx.compose?.services ?? {}).some(
(service) => service.build,
)
? false
: "No buildable services",
task: async (taskCtx, task) => {
const built = await buildServices(project, taskCtx.compose!, cwd, {
progress: (message) => {
task.output = message;
},
stream: task.stdout(),
});
task.output = `Built ${built} image${built === 1 ? "" : "s"}`;
},
},
enabled: () => build,
skip: (taskCtx) =>
Object.values(taskCtx.compose?.services ?? {}).some((service) => service.build)
? false
: "No buildable services",
task: async (taskCtx, task) => {
const built = await buildServices(project, taskCtx.compose!, cwd, {
progress: (message) => {
task.output = message;
},
stream: task.stdout(),
});
task.output = `Built ${built} image${built === 1 ? "" : "s"}`;
{
title: "Reconcile databases",
skip: (taskCtx) =>
getComposePostgresClaims(taskCtx.compose!).length > 0
? false
: "No managed postgres volumes",
task: async (taskCtx, task) => {
taskCtx.serviceEnv = await reconcilePostgresClaims(
project,
taskCtx.compose!,
);
task.output = `${Object.keys(taskCtx.serviceEnv).length} service${Object.keys(taskCtx.serviceEnv).length === 1 ? "" : "s"}`;
},
},
},
{
title: "Render manifests",
task: async (taskCtx, task) => {
taskCtx.resources = await composeToKubernetes(project, taskCtx.compose!, cwd);
task.output = `${taskCtx.resources.length} resources`;
{
title: "Render manifests",
task: async (taskCtx, task) => {
taskCtx.resources = await composeToKubernetes(
project,
taskCtx.compose!,
cwd,
taskCtx.serviceEnv,
);
task.output = `${taskCtx.resources.length} resources`;
},
},
},
{
title: "Plan reconciliation",
task: async (taskCtx, task) => {
taskCtx.staleResources = (await getStaleResources(
project,
taskCtx.resources!,
)) as KubernetesResource[];
task.output = `${taskCtx.staleResources.length} stale resource${taskCtx.staleResources.length === 1 ? "" : "s"}`;
{
title: "Plan reconciliation",
task: async (taskCtx, task) => {
taskCtx.staleResources = (await getStaleResources(
project,
taskCtx.resources!,
)) as KubernetesResource[];
task.output = `${taskCtx.staleResources.length} stale resource${taskCtx.staleResources.length === 1 ? "" : "s"}`;
},
},
},
{
title: "Apply resources",
task: (taskCtx, task) => {
const resources = sortResources(taskCtx.resources!);
{
title: "Apply resources",
task: (taskCtx, task) => {
const resources = sortResources(taskCtx.resources!);
task.output = `${resources.length} resources queued`;
return task.newListr(
resources.map((resource) => ({
title: `${resource.kind} ${resource.metadata?.name}`,
task: () => applyResource(resource),
})),
{ concurrent: false, exitOnError: true },
);
task.output = `${resources.length} resources queued`;
return task.newListr(
resources.map((resource) => ({
title: `${resource.kind} ${resource.metadata?.name}`,
task: () => applyResource(resource),
})),
{ concurrent: false, exitOnError: true },
);
},
},
},
{
title: "Restart deployments",
task: (taskCtx, task) => {
const deployments = getDeploymentNames(taskCtx.resources!);
{
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 },
);
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!);
{
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 },
);
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) =>
taskCtx.staleResources && taskCtx.staleResources.length > 0
? false
: "No stale resources",
task: (taskCtx, task) => {
const resources = sortResources(taskCtx.staleResources!).reverse();
{
title: "Delete stale resources",
skip: (taskCtx) =>
taskCtx.staleResources && taskCtx.staleResources.length > 0
? false
: "No stale resources",
task: (taskCtx, task) => {
const resources = sortResources(taskCtx.staleResources!).reverse();
task.output = `${resources.length} resources queued`;
return task.newListr(
resources.map((resource) => ({
title: `${resource.kind} ${resource.metadata?.name}`,
task: () => deleteResource(resource),
})),
{ concurrent: false, exitOnError: true },
);
task.output = `${resources.length} resources queued`;
return task.newListr(
resources.map((resource) => ({
title: `${resource.kind} ${resource.metadata?.name}`,
task: () => deleteResource(resource),
})),
{ concurrent: false, exitOnError: true },
);
},
},
},
], { rendererOptions: { collapseErrors: false } }).run();
],
{ rendererOptions: { collapseErrors: false } },
).run();
}
export const up = defineCommand({