184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
import { defineCommand } from "citty";
|
|
import { apiRequest, KuberApiError, type ApiRequestInit } from "../lib/api";
|
|
import { readSession, type KuberSession } from "../lib/session";
|
|
import { resolveTrustIdentity, updateTrust } from "../lib/trust";
|
|
import type { WorkspaceTrustResponse } from "../shared/api";
|
|
import { interactiveLogin } from "./auth";
|
|
import { createCompose, projectName } from "../lib/scaffold";
|
|
import {
|
|
appArgs,
|
|
appInputFromArgs,
|
|
resolveAppInput,
|
|
terminalPrompt,
|
|
type AppInput,
|
|
type ScaffoldPrompt,
|
|
} from "./add";
|
|
import { resolveComposeFile } from "../lib/yaml";
|
|
import { basename } from "node:path";
|
|
import { join } from "node:path";
|
|
import { readFile, rm } from "node:fs/promises";
|
|
import { renderDockerfileTemplate } from "../lib/scaffold-templates";
|
|
import { loadConfig } from "../lib/config";
|
|
|
|
type Requester = <T>(path: string, init?: ApiRequestInit) => Promise<T>;
|
|
export type InitDependencies = {
|
|
configPath?: string;
|
|
prompt?: ScaffoldPrompt;
|
|
session?: () => Promise<KuberSession | undefined>;
|
|
login?: () => Promise<KuberSession>;
|
|
request?: Requester;
|
|
persistTrust?: typeof updateTrust;
|
|
};
|
|
|
|
/** Creates a project and registers trust after a successful authenticated server grant. */
|
|
export async function initializeProject(
|
|
root: string,
|
|
input: AppInput & { project?: string },
|
|
dependencies: InitDependencies = {},
|
|
): Promise<string> {
|
|
if (await resolveComposeFile(root))
|
|
throw new Error("A Compose file already exists; use kuber add app instead");
|
|
const config = await loadConfig(root, dependencies.configPath);
|
|
if (config.composeFile && config.composeFile !== join(root, "compose.yml"))
|
|
throw new Error(
|
|
`Existing config expects ${config.composeFile}; kuber init creates compose.yml`,
|
|
);
|
|
if (
|
|
config.projectConfigured &&
|
|
projectName(config.project) !== config.project
|
|
)
|
|
throw new Error(
|
|
`Configured project ${config.project} must be a lowercase Kubernetes name`,
|
|
);
|
|
const prompt = dependencies.prompt ?? terminalPrompt;
|
|
if (
|
|
config.projectConfigured &&
|
|
input.project &&
|
|
input.project !== config.project
|
|
)
|
|
throw new Error(
|
|
`Existing config selects project ${config.project}; use that project for init`,
|
|
);
|
|
const defaultProject = projectName(
|
|
config.projectConfigured ? config.project : basename(root),
|
|
);
|
|
const project = projectName(
|
|
input.project ??
|
|
(input.nonInteractive
|
|
? defaultProject
|
|
: await prompt("Project name", defaultProject)),
|
|
);
|
|
const app = await resolveAppInput(root, input, prompt);
|
|
const login = dependencies.login ?? interactiveLogin;
|
|
let session = await (dependencies.session ?? readSession)();
|
|
if (!session) session = await login();
|
|
const composePath = await createCompose(root, project, app);
|
|
const createdCompose = await readFile(composePath, "utf8");
|
|
const identity = await resolveTrustIdentity(project, root);
|
|
const request = dependencies.request ?? apiRequest;
|
|
const trustPath = `/workspaces/${encodeURIComponent(project)}/trust`;
|
|
let retried = false;
|
|
let remoteRegistered = false;
|
|
try {
|
|
while (true) {
|
|
try {
|
|
const remote = await request<WorkspaceTrustResponse>(trustPath);
|
|
if (!Array.isArray(remote?.fingerprints))
|
|
throw new Error("Could not verify existing server trust");
|
|
const alreadyTrusted = remote.fingerprints.includes(
|
|
identity.fingerprint,
|
|
);
|
|
// The server grant is idempotent. A GET cannot establish ownership of it:
|
|
// another client may grant the same fingerprint before our POST.
|
|
if (!alreadyTrusted)
|
|
await request(trustPath, {
|
|
method: "POST",
|
|
json: { fingerprint: identity.fingerprint },
|
|
});
|
|
remoteRegistered = true;
|
|
try {
|
|
await (dependencies.persistTrust ?? updateTrust)((records) => [
|
|
...records.filter(
|
|
(record) =>
|
|
record.project !== identity.project ||
|
|
record.fingerprint !== identity.fingerprint,
|
|
),
|
|
identity,
|
|
]);
|
|
} catch {
|
|
throw new Error(
|
|
"Server registration exists, but local trust could not be confirmed. Generated project files were kept; run kuber trust to repair local trust.",
|
|
);
|
|
}
|
|
break;
|
|
} catch (error) {
|
|
if (
|
|
retried ||
|
|
!(error instanceof KuberApiError) ||
|
|
error.status !== 401
|
|
)
|
|
throw error;
|
|
retried = true;
|
|
session = await login();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Once registered remotely, keep the files needed to repair local trust.
|
|
// On remote failure, only roll back files still identical to ours.
|
|
if (
|
|
!remoteRegistered &&
|
|
(await readFile(composePath, "utf8").catch(() => undefined)) ===
|
|
createdCompose
|
|
) {
|
|
await rm(composePath, { force: true });
|
|
if (app.source.kind === "template") {
|
|
const { dockerfile, dockerignore } = renderDockerfileTemplate(
|
|
app.source.template,
|
|
);
|
|
for (const [name, content] of [
|
|
["Dockerfile", dockerfile],
|
|
[".dockerignore", dockerignore],
|
|
] as const) {
|
|
const file = join(root, app.path, name);
|
|
if ((await readFile(file, "utf8").catch(() => undefined)) === content)
|
|
await rm(file, { force: true });
|
|
}
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
console.log(`Trusted this directory for namespace ${identity.project}`);
|
|
console.log(`Initialized ${project} in ${composePath}`);
|
|
return composePath;
|
|
}
|
|
|
|
export const init = defineCommand({
|
|
meta: {
|
|
name: "init",
|
|
description: "Initialize a Compose project and trust this directory",
|
|
},
|
|
args: {
|
|
...appArgs,
|
|
project: {
|
|
type: "string",
|
|
description: "Kubernetes namespace/project name",
|
|
},
|
|
"config-path": {
|
|
type: "string",
|
|
description: "Project config path (usually passed as global --config)",
|
|
},
|
|
},
|
|
async run({ args }) {
|
|
await initializeProject(
|
|
process.cwd(),
|
|
{
|
|
...appInputFromArgs(args),
|
|
...(args.project !== undefined
|
|
? { project: String(args.project) }
|
|
: {}),
|
|
},
|
|
{ configPath: args["config-path"] },
|
|
);
|
|
},
|
|
});
|