feat: v2
This commit is contained in:
+238
-103
@@ -2,39 +2,59 @@ import { defineCommand } from "citty";
|
||||
import { Listr } from "listr2";
|
||||
import type { ComposeSpecification } from "../schema/docker.d";
|
||||
import type { KuberResource } from "../types";
|
||||
import { ctx } from "../lib/context";
|
||||
import { apiRequest, KuberApiError, type ApiRequestInit } from "../lib/api";
|
||||
import {
|
||||
assertManagedNamespace,
|
||||
applyResource,
|
||||
deleteResource,
|
||||
getStaleResources,
|
||||
sortResources,
|
||||
} from "../lib/apply";
|
||||
import { buildServices, resolveBuildImages } from "../lib/build";
|
||||
buildServices,
|
||||
getRepoRoot,
|
||||
resolveBuildImages,
|
||||
type ApiRequester,
|
||||
} from "../lib/build";
|
||||
import { composeToKubernetes, type KubernetesResource } from "../lib/convert";
|
||||
import {
|
||||
getComposePostgresClaims,
|
||||
reconcilePostgresClaims,
|
||||
} from "../lib/database";
|
||||
import { getComposeS3Claims, reconcileS3Claims } from "../lib/storage";
|
||||
import { waitForDeploymentRollout } from "../lib/shared";
|
||||
import { ctx } from "../lib/context";
|
||||
import { getComposePostgresClaims } from "../lib/database";
|
||||
import { getComposeS3Claims } from "../lib/storage";
|
||||
import { enumerateWorkspace, type WorkspaceSnapshot } from "../lib/workspace";
|
||||
|
||||
type Workspace = {
|
||||
metadata: { name: string; uid: string; resourceVersion: string };
|
||||
};
|
||||
|
||||
type ResourceIdentity = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
uid: string;
|
||||
workspaceUid: string;
|
||||
};
|
||||
|
||||
type ResourcePlan = {
|
||||
desired: KubernetesResource[];
|
||||
stale: ResourceIdentity[];
|
||||
};
|
||||
|
||||
type UpContext = {
|
||||
compose?: ComposeSpecification;
|
||||
snapshot?: WorkspaceSnapshot;
|
||||
buildImages?: Record<string, string>;
|
||||
serviceEnv?: Record<string, Record<string, string>>;
|
||||
resources?: KubernetesResource[];
|
||||
staleResources?: KubernetesResource[];
|
||||
plan?: ResourcePlan;
|
||||
};
|
||||
|
||||
export const WORKSPACE_ADOPTION_METHOD = "POST";
|
||||
|
||||
export function workspaceAdoptionRoute(project: string): string {
|
||||
return `/workspaces/${encodeURIComponent(project)}/adopt`;
|
||||
}
|
||||
|
||||
export function mergeServiceEnv(
|
||||
current: Record<string, Record<string, string>> | undefined,
|
||||
next: Record<string, Record<string, string>>,
|
||||
): Record<string, Record<string, string>> {
|
||||
const merged = { ...current };
|
||||
for (const [service, environment] of Object.entries(next)) {
|
||||
for (const [service, environment] of Object.entries(next))
|
||||
merged[service] = { ...merged[service], ...environment };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -45,9 +65,141 @@ export function getDeploymentNames(resources: KubernetesResource[]): string[] {
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
export async function runUp(build: boolean) {
|
||||
function workspaceInput(
|
||||
compose: ComposeSpecification,
|
||||
snapshot: WorkspaceSnapshot,
|
||||
) {
|
||||
return {
|
||||
source: {
|
||||
uri: `cas://${snapshot.digest}`,
|
||||
digest: snapshot.digest,
|
||||
},
|
||||
config: { compose },
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureWorkspace(
|
||||
project: string,
|
||||
compose: ComposeSpecification,
|
||||
snapshot: WorkspaceSnapshot,
|
||||
request: ApiRequester = apiRequest,
|
||||
): Promise<Workspace> {
|
||||
const path = `/workspaces/${encodeURIComponent(project)}`;
|
||||
const input = workspaceInput(compose, snapshot);
|
||||
let current: Workspace;
|
||||
try {
|
||||
current = await request<Workspace>(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof KuberApiError) || error.status !== 404) throw error;
|
||||
return request<Workspace>("/workspaces", {
|
||||
method: "POST",
|
||||
json: { id: project, ...input },
|
||||
});
|
||||
}
|
||||
return request<Workspace>(path, {
|
||||
method: "PUT",
|
||||
headers: { "if-match": `"${current.metadata.resourceVersion}"` },
|
||||
json: input,
|
||||
});
|
||||
}
|
||||
|
||||
function operationEnvironment(
|
||||
response: Record<string, unknown>,
|
||||
): Record<string, Record<string, string>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(response).filter(
|
||||
([key, value]) =>
|
||||
key !== "operation" &&
|
||||
key !== "operationId" &&
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value),
|
||||
),
|
||||
) as Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
function adoptionHint(project: string, error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/namespace .* (?:external|different-workspace)/i.test(message))
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
return new Error(
|
||||
`${message}. Safe adoption requires ${WORKSPACE_ADOPTION_METHOD} ${workspaceAdoptionRoute(project)} with namespace UID and ownership preconditions.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
async function managementRequest<T>(
|
||||
project: string,
|
||||
request: ApiRequester,
|
||||
path: string,
|
||||
init: ApiRequestInit,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await request<T>(path, init);
|
||||
} catch (error) {
|
||||
throw adoptionHint(project, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileResources(
|
||||
project: string,
|
||||
resources: KubernetesResource[],
|
||||
rolloutTimeoutMs: number,
|
||||
postApply:
|
||||
| ((resources: KubernetesResource[]) => void | Promise<void>)
|
||||
| undefined,
|
||||
request: ApiRequester = apiRequest,
|
||||
): Promise<ResourcePlan> {
|
||||
const workspacePath = `/workspaces/${encodeURIComponent(project)}`;
|
||||
const plan = await managementRequest<ResourcePlan>(
|
||||
project,
|
||||
request,
|
||||
`${workspacePath}/resources/plan`,
|
||||
{ method: "POST", json: { resources } },
|
||||
);
|
||||
await managementRequest(
|
||||
project,
|
||||
request,
|
||||
`${workspacePath}/resources/apply`,
|
||||
{
|
||||
method: "POST",
|
||||
json: { resources: plan.desired },
|
||||
},
|
||||
);
|
||||
await postApply?.(plan.desired);
|
||||
|
||||
const deployments = getDeploymentNames(plan.desired);
|
||||
if (deployments.length > 0) {
|
||||
await managementRequest(
|
||||
project,
|
||||
request,
|
||||
`${workspacePath}/resources/wait`,
|
||||
{
|
||||
method: "POST",
|
||||
json: { deployments, timeoutMs: rolloutTimeoutMs },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (plan.stale.length > 0) {
|
||||
await managementRequest(
|
||||
project,
|
||||
request,
|
||||
`${workspacePath}/resources/delete`,
|
||||
{
|
||||
method: "POST",
|
||||
json: { resources: plan.stale },
|
||||
},
|
||||
);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export async function runUp(
|
||||
build: boolean,
|
||||
request: ApiRequester = apiRequest,
|
||||
) {
|
||||
const { project, compose, cwd, config, hookContext: getHookContext } = ctx();
|
||||
await assertManagedNamespace(project);
|
||||
const workspacePath = `/workspaces/${encodeURIComponent(project)}`;
|
||||
|
||||
const taskCtx = await new Listr<UpContext>(
|
||||
[
|
||||
@@ -55,18 +207,21 @@ export async function runUp(build: boolean) {
|
||||
title: "Read compose",
|
||||
task: async (taskCtx, task) => {
|
||||
taskCtx.compose = await compose();
|
||||
if (build) {
|
||||
if (build)
|
||||
await config.preBuild?.(taskCtx.compose, await getHookContext());
|
||||
}
|
||||
task.output = `${Object.keys(taskCtx.compose.services ?? {}).length} services`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Build images",
|
||||
rendererOptions: {
|
||||
outputBar: 10,
|
||||
persistentOutput: true,
|
||||
title: "Snapshot workspace",
|
||||
task: async (taskCtx, task) => {
|
||||
taskCtx.snapshot = await enumerateWorkspace(await getRepoRoot(cwd));
|
||||
task.output = `${taskCtx.snapshot.manifest.files.length} files`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Build images",
|
||||
rendererOptions: { outputBar: 10, persistentOutput: true },
|
||||
enabled: async () =>
|
||||
build &&
|
||||
Object.values((await compose()).services ?? {}).some(
|
||||
@@ -83,11 +238,11 @@ export async function runUp(build: boolean) {
|
||||
},
|
||||
stream: task.stdout(),
|
||||
},
|
||||
config,
|
||||
{ ...config, request, snapshot: taskCtx.snapshot },
|
||||
);
|
||||
taskCtx.buildImages = result.images;
|
||||
await config.postBuild?.(result, await getHookContext());
|
||||
task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}, ${result.changed.length} changed`;
|
||||
task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -101,9 +256,33 @@ export async function runUp(build: boolean) {
|
||||
taskCtx.buildImages = await resolveBuildImages(
|
||||
project,
|
||||
taskCtx.compose!,
|
||||
config,
|
||||
{
|
||||
...config,
|
||||
request,
|
||||
},
|
||||
);
|
||||
task.output = `${Object.keys(taskCtx.buildImages).length} image${Object.keys(taskCtx.buildImages).length === 1 ? "" : "s"}`;
|
||||
task.output = `${Object.keys(taskCtx.buildImages).length} images`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Update workspace",
|
||||
task: async (taskCtx, task) => {
|
||||
const workspace = await ensureWorkspace(
|
||||
project,
|
||||
taskCtx.compose!,
|
||||
taskCtx.snapshot!,
|
||||
request,
|
||||
);
|
||||
const adopted = await request<{ resourcesAdopted: number }>(
|
||||
workspaceAdoptionRoute(project),
|
||||
{
|
||||
method: WORKSPACE_ADOPTION_METHOD,
|
||||
json: { workspaceUid: workspace.metadata.uid },
|
||||
},
|
||||
);
|
||||
task.output = adopted.resourcesAdopted
|
||||
? `Adopted ${adopted.resourcesAdopted} existing resources`
|
||||
: "Workspace ready";
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -111,22 +290,34 @@ export async function runUp(build: boolean) {
|
||||
enabled: async () =>
|
||||
getComposePostgresClaims(await compose()).length > 0,
|
||||
task: async (taskCtx, task) => {
|
||||
const response = await managementRequest<Record<string, unknown>>(
|
||||
project,
|
||||
request,
|
||||
`${workspacePath}/databases`,
|
||||
{ method: "POST", json: { compose: taskCtx.compose } },
|
||||
);
|
||||
taskCtx.serviceEnv = mergeServiceEnv(
|
||||
taskCtx.serviceEnv,
|
||||
await reconcilePostgresClaims(project, taskCtx.compose!),
|
||||
operationEnvironment(response),
|
||||
);
|
||||
task.output = `${Object.keys(taskCtx.serviceEnv).length} service${Object.keys(taskCtx.serviceEnv).length === 1 ? "" : "s"}`;
|
||||
task.output = `${Object.keys(taskCtx.serviceEnv).length} services`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Reconcile S3 storage",
|
||||
enabled: async () => getComposeS3Claims(await compose()).length > 0,
|
||||
task: async (taskCtx, task) => {
|
||||
const response = await managementRequest<Record<string, unknown>>(
|
||||
project,
|
||||
request,
|
||||
`${workspacePath}/storage`,
|
||||
{ method: "POST", json: { compose: taskCtx.compose } },
|
||||
);
|
||||
taskCtx.serviceEnv = mergeServiceEnv(
|
||||
taskCtx.serviceEnv,
|
||||
await reconcileS3Claims(project, taskCtx.compose!),
|
||||
operationEnvironment(response),
|
||||
);
|
||||
task.output = `${Object.keys(taskCtx.serviceEnv).length} service${Object.keys(taskCtx.serviceEnv).length === 1 ? "" : "s"}`;
|
||||
task.output = `${Object.keys(taskCtx.serviceEnv).length} services`;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -147,89 +338,33 @@ export async function runUp(build: boolean) {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Plan reconciliation",
|
||||
title: "Reconcile resources",
|
||||
task: async (taskCtx, task) => {
|
||||
taskCtx.staleResources = (await getStaleResources(
|
||||
taskCtx.plan = await reconcileResources(
|
||||
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!);
|
||||
|
||||
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: "Run post-apply hook",
|
||||
enabled: () => Boolean(config.postApply),
|
||||
task: async (taskCtx) => {
|
||||
await config.postApply?.(
|
||||
taskCtx.resources! as KuberResource[],
|
||||
await getHookContext(),
|
||||
config.rolloutTimeoutMs,
|
||||
config.postApply
|
||||
? async (resources) =>
|
||||
config.postApply?.(
|
||||
resources as KuberResource[],
|
||||
await getHookContext(),
|
||||
)
|
||||
: undefined,
|
||||
request,
|
||||
);
|
||||
task.output = `${taskCtx.plan.desired.length} applied, ${taskCtx.plan.stale.length} stale deleted`;
|
||||
},
|
||||
},
|
||||
],
|
||||
{ rendererOptions: { collapseErrors: false } },
|
||||
).run();
|
||||
|
||||
const deployments = getDeploymentNames(taskCtx.resources!);
|
||||
if (deployments.length > 0) {
|
||||
await new Listr([
|
||||
{
|
||||
title: "Wait for rollout",
|
||||
task: (_taskCtx, task) => {
|
||||
task.output = `${deployments.length} deployments queued`;
|
||||
return task.newListr(
|
||||
deployments.map((name) => ({
|
||||
title: `Deployment ${name}`,
|
||||
task: () =>
|
||||
waitForDeploymentRollout(name, config.rolloutTimeoutMs),
|
||||
})),
|
||||
{ concurrent: false, exitOnError: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
]).run();
|
||||
}
|
||||
|
||||
if (taskCtx.staleResources && taskCtx.staleResources.length > 0) {
|
||||
const resources = sortResources(taskCtx.staleResources).reverse();
|
||||
await new Listr([
|
||||
{
|
||||
title: "Delete stale resources",
|
||||
task: (_taskCtx, task) => {
|
||||
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 },
|
||||
);
|
||||
},
|
||||
},
|
||||
]).run();
|
||||
}
|
||||
return taskCtx;
|
||||
}
|
||||
|
||||
export const up = defineCommand({
|
||||
meta: {
|
||||
name: "up",
|
||||
description: "Create and start deployments",
|
||||
},
|
||||
meta: { name: "up", description: "Create and start deployments" },
|
||||
args: {
|
||||
build: {
|
||||
type: "boolean",
|
||||
|
||||
Reference in New Issue
Block a user