feat: add kubernetes execution target provisioning

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-12 10:03:32 -05:00
1 parent 0808b388ee
commit 4a7a45f960
87 files changed
+15005 -131

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
/**
* CLI wiring for `paperclipai cluster <subcommand>`.
*
* Bridges Commander with the pure createClusterCommand() factory,
* constructing real service deps from DB + Kubernetes lazily on demand.
*
* Service-access pattern: direct DB (no HTTP routes exist yet for cluster ops).
*/
import type { Command } from "commander";
import { createDb, companies, eq } from "@paperclipai/db";
import { clusterConnectionsService } from "@paperclipai/server/services/cluster-connections";
import { clusterTenantPoliciesService } from "@paperclipai/server/services/cluster-tenant-policies";
import { clusterNamespaceBindingsService } from "@paperclipai/server/services/cluster-namespace-bindings";
import { createKubernetesExecutionDriver } from "@paperclipai/execution-target-kubernetes";
import { getSecretProvider } from "@paperclipai/server/secrets/provider-registry";
import { readConfig } from "../config/store.js";
import { createClusterCommand, deriveCompanySlug } from "./cluster.js";
function resolveDbUrl(configPath?: string): string {
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
const config = readConfig(configPath);
if (config?.database.mode === "postgres" && config.database.connectionString) {
return config.database.connectionString;
}
const port = config?.database.embeddedPostgresPort ?? 54329;
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
function buildDeps(opts: { config?: string }) {
const db = createDb(resolveDbUrl(opts.config));
const connsSvc = clusterConnectionsService(db, {
resolveSecret: async (ref) => {
const provider = getSecretProvider(ref.provider as Parameters<typeof getSecretProvider>[0]);
return provider.resolveVersion({ material: {}, externalRef: ref.name });
},
});
const driver = createKubernetesExecutionDriver({
resolveConnection: (id) => connsSvc.resolve(id),
});
return {
clusterConnections: connsSvc,
tenantPolicies: clusterTenantPoliciesService(db),
driver,
companies: {
async getById(id: string) {
const [row] = await db.select().from(companies).where(eq(companies.id, id));
if (!row) return null;
return {
id: row.id,
name: row.name,
slug: deriveCompanySlug(row.name),
};
},
},
namespaceBindings: clusterNamespaceBindingsService(db),
print: (line: string) => console.log(line),
};
}
export function registerClusterCommands(program: Command): void {
const clusterCmd = program
.command("cluster")
.description("Manage Kubernetes cluster connections and tenant provisioning")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", "Paperclip data directory root");
clusterCmd
.command("add")
.description("Register a new cluster connection")
.requiredOption("--label <name>", "Human-readable label")
.requiredOption("--kind <kind>", "Connection kind: in-cluster | kubeconfig")
.option("--kubeconfig-secret <ref>", "Secret reference in <provider>:<name> format")
.option("--paperclip-public-url <url>", "Public URL of this Paperclip instance")
.option("--image-registry <url>", "Container image registry URL")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts() as { config?: string };
const deps = buildDeps(globalOpts);
const args = [
"add",
"--label", opts.label,
"--kind", opts.kind,
...(opts.kubeconfigSecret ? ["--kubeconfig-secret", opts.kubeconfigSecret] : []),
...(opts.paperclipPublicUrl ? ["--paperclip-public-url", opts.paperclipPublicUrl] : []),
...(opts.imageRegistry ? ["--image-registry", opts.imageRegistry] : []),
];
const code = await createClusterCommand(deps).run(args);
if (code !== 0) process.exit(code);
});
clusterCmd
.command("list")
.description("List all cluster connections")
.action(async (_opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts() as { config?: string };
const deps = buildDeps(globalOpts);
const code = await createClusterCommand(deps).run(["list"]);
if (code !== 0) process.exit(code);
});
clusterCmd
.command("test <id>")
.description("Connect to a cluster and probe its capabilities")
.action(async (id, _opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts() as { config?: string };
const deps = buildDeps(globalOpts);
const code = await createClusterCommand(deps).run(["test", id]);
if (code !== 0) process.exit(code);
});
clusterCmd
.command("remove <id>")
.description("Remove a cluster connection")
.action(async (id, _opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts() as { config?: string };
const deps = buildDeps(globalOpts);
const code = await createClusterCommand(deps).run(["remove", id]);
if (code !== 0) process.exit(code);
});
clusterCmd
.command("ensure-tenant <clusterId> <companyId>")
.description("Provision a tenant namespace for a company on the given cluster")
.action(async (clusterId, companyId, _opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts() as { config?: string };
const deps = buildDeps(globalOpts);
const code = await createClusterCommand(deps).run(["ensure-tenant", clusterId, companyId]);
if (code !== 0) process.exit(code);
});
clusterCmd
.command("doctor <id>")
.description("Run M1 health checks on a cluster connection")
.action(async (id, _opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts() as { config?: string };
const deps = buildDeps(globalOpts);
const code = await createClusterCommand(deps).run(["doctor", id]);
if (code !== 0) process.exit(code);
});
}
+296
View File
@@ -0,0 +1,296 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createClusterCommand, type ClusterCommandDeps } from "./cluster.js";
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
const MOCK_ROW = {
id: "c-1",
label: "kind",
kind: "kubeconfig" as const,
kubeconfigSecretRef: null,
apiServerUrl: null,
defaultNamespacePrefix: "paperclip-",
capabilities: { cilium: false, storageClass: "standard", architectures: ["amd64"] as const },
paperclipPublicUrl: null,
imageRegistry: null,
allowAgentImageOverride: false,
createdAt: new Date(),
createdBy: "x",
};
const MOCK_RESOLVED = {
...MOCK_ROW,
kubeconfigYaml: "<yaml>",
};
function mocks(): ClusterCommandDeps {
return {
clusterConnections: {
create: vi.fn(async (i: { label: string }) => ({
...MOCK_ROW,
id: "c-1",
label: i.label,
})) as any,
list: vi.fn(async () => [MOCK_ROW]) as any,
get: vi.fn(async () => MOCK_ROW) as any,
delete: vi.fn(async () => {}) as any,
resolve: vi.fn(async () => MOCK_RESOLVED) as any,
},
tenantPolicies: {
get: vi.fn(async () => null) as any,
upsert: vi.fn(async () => ({} as any)) as any,
},
driver: {
type: "kubernetes" as const,
validateTarget: vi.fn(async () => {}) as any,
ensureTenant: vi.fn(async () => ({
namespace: "paperclip-acme",
ciliumApplied: false,
})) as any,
run: vi.fn() as any,
},
companies: {
getById: vi.fn(async () => ({ id: "co-1", name: "Acme Corp", slug: "acme" })),
},
namespaceBindings: {
record: vi.fn(async () => {}),
},
print: (s: string) => out.push(s),
};
}
let out: string[];
beforeEach(() => {
out = [];
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("cluster commands", () => {
it("add: creates a connection and prints its id and label", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run([
"add",
"--label", "kind",
"--kind", "kubeconfig",
"--kubeconfig-secret", "local_encrypted:my-cfg",
]);
expect(code).toBe(0);
expect(out.join("\n")).toContain("c-1");
expect(m.clusterConnections.create).toHaveBeenCalled();
// kubeconfig-secret parsed correctly
const arg = (m.clusterConnections.create as any).mock.calls[0][0];
expect(arg.kubeconfigSecretRef).toEqual({ provider: "local_encrypted", name: "my-cfg" });
});
it("add: returns non-zero when --label is missing", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["add", "--kind", "kubeconfig"]);
expect(code).not.toBe(0);
expect(m.clusterConnections.create).not.toHaveBeenCalled();
});
it("add: returns non-zero when --kind is invalid", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["add", "--label", "x", "--kind", "nomad"]);
expect(code).not.toBe(0);
});
it("add: passes --cilium / --storage-class / --arch through to capabilities", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run([
"add",
"--label", "prod-eks",
"--kind", "kubeconfig",
"--kubeconfig-secret", "aws_secrets:prod",
"--cilium",
"--storage-class", "gp3",
"--arch", "amd64,arm64",
]);
expect(code).toBe(0);
const arg = (m.clusterConnections.create as any).mock.calls[0][0];
expect(arg.capabilities).toEqual({
cilium: true,
storageClass: "gp3",
architectures: ["amd64", "arm64"],
});
});
it("add: defaults capabilities to single-arch x86 without Cilium when no flags are passed", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run([
"add",
"--label", "kind",
"--kind", "kubeconfig",
"--kubeconfig-secret", "local_encrypted:my-cfg",
]);
expect(code).toBe(0);
const arg = (m.clusterConnections.create as any).mock.calls[0][0];
expect(arg.capabilities).toEqual({
cilium: false,
storageClass: "standard",
architectures: ["amd64"],
});
});
it("add: rejects --arch with an unsupported value", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run([
"add",
"--label", "x",
"--kind", "kubeconfig",
"--kubeconfig-secret", "local_encrypted:y",
"--arch", "ppc64le",
]);
expect(code).not.toBe(0);
expect(m.clusterConnections.create).not.toHaveBeenCalled();
});
it("list: prints connections with capabilities", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["list"]);
expect(code).toBe(0);
const printed = out.join("\n");
expect(printed).toContain("kind");
expect(printed).toContain("standard");
expect(printed).toContain("amd64");
});
it("list: prints a message when there are no connections", async () => {
const m = mocks();
(m.clusterConnections.list as any).mockResolvedValue([]);
const cmd = createClusterCommand(m);
const code = await cmd.run(["list"]);
expect(code).toBe(0);
expect(out.join("\n")).toMatch(/no cluster/i);
});
it("test: prints resolved connection details", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["test", "c-1"]);
expect(code).toBe(0);
const printed = out.join("\n");
expect(printed).toMatch(/ok/i);
expect(printed).toContain("standard");
expect(printed).toContain("amd64");
});
it("test: returns non-zero when connection is not found", async () => {
const m = mocks();
(m.clusterConnections.resolve as any).mockResolvedValue(null);
const cmd = createClusterCommand(m);
const code = await cmd.run(["test", "c-missing"]);
expect(code).not.toBe(0);
expect(out.join("\n")).toMatch(/not found/i);
});
it("ensure-tenant: calls driver.ensureTenant, records the binding, and prints the namespace", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["ensure-tenant", "c-1", "co-1"]);
expect(code).toBe(0);
expect(out.join("\n")).toContain("paperclip-acme");
expect(m.driver.ensureTenant).toHaveBeenCalled();
expect(m.namespaceBindings.record).toHaveBeenCalledWith({
clusterConnectionId: "c-1",
companyId: "co-1",
namespaceName: "paperclip-acme",
});
});
it("ensure-tenant: passes slug from company object to driver", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
await cmd.run(["ensure-tenant", "c-1", "co-1"]);
const arg = (m.driver.ensureTenant as any).mock.calls[0][0];
expect(arg.company.slug).toBe("acme");
});
it("ensure-tenant: returns non-zero exit code when company is not found", async () => {
const m = mocks();
(m.companies.getById as any).mockResolvedValue(null);
const cmd = createClusterCommand(m);
const code = await cmd.run(["ensure-tenant", "c-1", "co-missing"]);
expect(code).not.toBe(0);
expect(out.join("\n")).toMatch(/not found/i);
expect(m.driver.ensureTenant).not.toHaveBeenCalled();
expect(m.namespaceBindings.record).not.toHaveBeenCalled();
});
it("ensure-tenant: returns non-zero when args are missing", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["ensure-tenant", "c-1"]);
expect(code).not.toBe(0);
});
it("remove: calls clusterConnections.delete", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["remove", "c-1"]);
expect(code).toBe(0);
expect(m.clusterConnections.delete).toHaveBeenCalledWith("c-1");
});
it("remove: returns non-zero when id is missing", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["remove"]);
expect(code).not.toBe(0);
});
it("doctor: validates connection, probes capabilities, prints results", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["doctor", "c-1"]);
expect(code).toBe(0);
const printed = out.join("\n");
expect(printed).toMatch(/storageClass|cilium|amd64/i);
expect(printed).toContain("ClusterRole");
});
it("doctor: returns non-zero when connection is not found", async () => {
const m = mocks();
(m.clusterConnections.resolve as any).mockResolvedValue(null);
const cmd = createClusterCommand(m);
const code = await cmd.run(["doctor", "c-missing"]);
expect(code).not.toBe(0);
expect(out.join("\n")).toMatch(/not found/i);
});
it("doctor: returns non-zero when id is missing", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["doctor"]);
expect(code).not.toBe(0);
});
it("unknown subcommand: non-zero exit and usage hint", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run(["nonsense"]);
expect(code).not.toBe(0);
expect(out.join("\n")).toMatch(/usage|cluster/i);
});
it("no subcommand: non-zero exit and usage hint", async () => {
const m = mocks();
const cmd = createClusterCommand(m);
const code = await cmd.run([]);
expect(code).not.toBe(0);
expect(out.join("\n")).toMatch(/usage|cluster/i);
});
});
+407
View File
@@ -0,0 +1,407 @@
/**
* paperclipai cluster <subcommand>
*
* Subcommands:
* add --label <name> --kind <in-cluster|kubeconfig>
* [--kubeconfig-secret <provider:name>]
* [--paperclip-public-url <url>]
* [--image-registry <url>]
* list
* test <id>
* remove <id>
* ensure-tenant <clusterId> <companyId>
* doctor <id>
*
* Service-access pattern: direct DB (no HTTP server routes exist yet for cluster operations).
*
* The ClusterCommandDeps interface is injected so all logic is fully unit-testable
* without real DB or Kubernetes connectivity.
*/
// ---------------------------------------------------------------------------
// Dependency interfaces (mirroring the real service shapes without importing
// from server sub-paths that don't exist in the package exports map)
// ---------------------------------------------------------------------------
export type ClusterKind = "in-cluster" | "kubeconfig";
export type ClusterArch = "amd64" | "arm64";
export interface ClusterCapabilities {
cilium: boolean;
storageClass: string;
architectures: ClusterArch[];
}
export interface ClusterConnectionRow {
id: string;
label: string;
kind: ClusterKind;
kubeconfigSecretRef: { provider: string; name: string } | null;
apiServerUrl: string | null;
defaultNamespacePrefix: string;
capabilities: ClusterCapabilities;
paperclipPublicUrl: string | null;
imageRegistry: string | null;
allowAgentImageOverride: boolean;
createdAt: Date;
createdBy: string;
}
export interface ResolvedClusterConnection extends ClusterConnectionRow {
kubeconfigYaml?: string;
}
export interface CreateClusterConnectionInput {
label: string;
kind: ClusterKind;
kubeconfigSecretRef?: { provider: string; name: string };
apiServerUrl?: string;
defaultNamespacePrefix?: string;
capabilities: ClusterCapabilities;
paperclipPublicUrl?: string;
imageRegistry?: string;
allowAgentImageOverride?: boolean;
createdBy: string;
}
export interface ClusterConnectionsService {
create(input: CreateClusterConnectionInput): Promise<ClusterConnectionRow>;
list(): Promise<ClusterConnectionRow[]>;
get(id: string): Promise<ClusterConnectionRow | null>;
delete(id: string): Promise<void>;
resolve(id: string): Promise<ResolvedClusterConnection | null>;
}
export interface TenantPolicy {
quota: Record<string, string | number | undefined> | null;
limitRange: Record<string, unknown> | null;
additionalAllowFqdns: string[];
imageOverrides: Record<string, string> | null;
}
export interface TenantPolicyRow extends TenantPolicy {
clusterConnectionId: string;
companyId: string;
}
export interface ClusterTenantPoliciesService {
get(clusterConnectionId: string, companyId: string): Promise<TenantPolicyRow | null>;
}
export interface EnsureTenantResult {
namespace: string;
ciliumApplied: boolean;
}
export interface KubernetesDriver {
type: "kubernetes";
validateTarget(target: unknown): Promise<void>;
ensureTenant(input: {
clusterConnectionId: string;
company: { id: string; slug: string };
tenantPolicy: TenantPolicy | null;
driverServiceAccount: { name: string; namespace: string };
controlPlane: {
topology: "in-cluster" | "cross-cluster";
namespaceLabels: Record<string, string>;
podLabels: Record<string, string>;
};
adapterAllowFqdns: string[];
imagePullDockerConfigJson: string | null;
}): Promise<EnsureTenantResult>;
run(...args: unknown[]): unknown;
}
export interface CompaniesLookup {
getById(id: string): Promise<{ id: string; name: string; slug: string } | null>;
}
export interface NamespaceBindingsService {
record(input: {
clusterConnectionId: string;
companyId: string;
namespaceName: string;
}): Promise<void>;
}
export interface ClusterCommandDeps {
clusterConnections: ClusterConnectionsService;
tenantPolicies: ClusterTenantPoliciesService;
driver: KubernetesDriver;
companies: CompaniesLookup;
namespaceBindings: NamespaceBindingsService;
print: (line: string) => void;
}
export interface ClusterCommand {
run(argv: string[]): Promise<number>;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createClusterCommand(deps: ClusterCommandDeps): ClusterCommand {
return {
async run(argv) {
const [sub, ...rest] = argv;
switch (sub) {
case "add": return cmdAdd(rest, deps);
case "list": return cmdList(rest, deps);
case "test": return cmdTest(rest, deps);
case "remove": return cmdRemove(rest, deps);
case "ensure-tenant": return cmdEnsureTenant(rest, deps);
case "doctor": return cmdDoctor(rest, deps);
default:
deps.print(
`Unknown subcommand: ${sub ?? "(none)"}\n` +
`Usage: cluster <add|list|test|remove|ensure-tenant|doctor>`,
);
return 2;
}
},
};
}
// ---------------------------------------------------------------------------
// Minimal flag parser (keeps this module free of commander/yargs deps)
// ---------------------------------------------------------------------------
function parseFlags(argv: string[]): { positional: string[]; flags: Record<string, string> } {
const positional: string[] = [];
const flags: Record<string, string> = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const val = argv[i + 1];
if (val !== undefined && !val.startsWith("--")) {
flags[key] = val;
i++;
} else {
flags[key] = "true";
}
} else {
positional.push(a);
}
}
return { positional, flags };
}
// ---------------------------------------------------------------------------
// Derive a DNS-safe slug from company name (companies table has no slug col)
// ---------------------------------------------------------------------------
export function deriveCompanySlug(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "company"
);
}
// ---------------------------------------------------------------------------
// Subcommand implementations
// ---------------------------------------------------------------------------
async function cmdAdd(argv: string[], deps: ClusterCommandDeps): Promise<number> {
const { flags } = parseFlags(argv);
const label = flags["label"];
const kind = flags["kind"] as ClusterKind;
if (!label || !kind || (kind !== "in-cluster" && kind !== "kubeconfig")) {
deps.print(
"Usage: cluster add --label <name> --kind <in-cluster|kubeconfig> " +
"[--kubeconfig-secret <provider:name>] [--paperclip-public-url <url>] [--image-registry <url>] " +
"[--cilium] [--storage-class <name>] [--arch <amd64|arm64>] (--arch may repeat)",
);
return 2;
}
let kubeconfigSecretRef: { provider: string; name: string } | undefined;
if (flags["kubeconfig-secret"]) {
const colonIdx = flags["kubeconfig-secret"].indexOf(":");
if (colonIdx <= 0) {
deps.print("Invalid --kubeconfig-secret. Use <provider>:<name> (e.g. local_encrypted:my-cfg)");
return 2;
}
const provider = flags["kubeconfig-secret"].slice(0, colonIdx);
const name = flags["kubeconfig-secret"].slice(colonIdx + 1);
if (!name) {
deps.print("Invalid --kubeconfig-secret. Use <provider>:<name> (e.g. local_encrypted:my-cfg)");
return 2;
}
kubeconfigSecretRef = { provider, name };
}
const capabilities = parseCapabilityFlags(flags);
if (capabilities === null) {
deps.print("Invalid --arch. Allowed values: amd64, arm64.");
return 2;
}
// Defaults match the most common single-arch x86 cluster without Cilium installed.
// Operators must pass --cilium for clusters running Cilium so the agent
// egress NetworkPolicy + per-tenant CiliumNetworkPolicy actually engage.
// `paperclip cluster doctor` reports the detected installation so operators
// can verify before adding.
const created = await deps.clusterConnections.create({
label,
kind,
kubeconfigSecretRef,
paperclipPublicUrl: flags["paperclip-public-url"],
imageRegistry: flags["image-registry"],
capabilities,
createdBy: process.env.PAPERCLIP_CLI_USER ?? "cli",
});
deps.print(`Created cluster connection ${created.id} (${created.label})`);
deps.print(
` capabilities: cilium=${capabilities.cilium} storageClass=${capabilities.storageClass} archs=${capabilities.architectures.join(",")}`,
);
return 0;
}
/**
* Build a ClusterCapabilities object from CLI flags. Returns null when --arch
* carries an unsupported value.
*/
function parseCapabilityFlags(flags: Record<string, string>): ClusterCapabilities | null {
const cilium = flags["cilium"] === "true";
const storageClass = flags["storage-class"] ?? "standard";
const archRaw = flags["arch"];
// parseFlags only retains the last value of a repeated flag, so for
// multi-arch clusters the operator passes --arch as a comma list.
const architectures: ClusterCapabilities["architectures"] =
archRaw === undefined
? ["amd64"]
: (archRaw.split(",").map((s) => s.trim()).filter(Boolean) as ClusterCapabilities["architectures"]);
for (const a of architectures) {
if (a !== "amd64" && a !== "arm64") return null;
}
if (architectures.length === 0) return null;
return { cilium, storageClass, architectures };
}
async function cmdList(_argv: string[], deps: ClusterCommandDeps): Promise<number> {
const rows = await deps.clusterConnections.list();
if (rows.length === 0) {
deps.print("No cluster connections.");
return 0;
}
for (const r of rows) {
deps.print(
`${r.id}\t${r.label}\t${r.kind}\t` +
`cilium=${r.capabilities.cilium}\t` +
`storageClass=${r.capabilities.storageClass}\t` +
`archs=${r.capabilities.architectures.join(",")}`,
);
}
return 0;
}
async function cmdTest(argv: string[], deps: ClusterCommandDeps): Promise<number> {
const [id] = argv;
if (!id) {
deps.print("Usage: cluster test <id>");
return 2;
}
const resolved = await deps.clusterConnections.resolve(id);
if (!resolved) {
deps.print(`Cluster connection ${id} not found`);
return 1;
}
deps.print(`OK: connection ${id} resolves (label=${resolved.label})`);
deps.print(` kind: ${resolved.kind}`);
deps.print(` cilium: ${resolved.capabilities.cilium}`);
deps.print(` storageClass: ${resolved.capabilities.storageClass}`);
deps.print(` archs: ${resolved.capabilities.architectures.join(", ")}`);
return 0;
}
async function cmdRemove(argv: string[], deps: ClusterCommandDeps): Promise<number> {
const [id] = argv;
if (!id) {
deps.print("Usage: cluster remove <id>");
return 2;
}
await deps.clusterConnections.delete(id);
deps.print(`Deleted cluster connection ${id}`);
return 0;
}
async function cmdEnsureTenant(argv: string[], deps: ClusterCommandDeps): Promise<number> {
const [clusterId, companyId] = argv;
if (!clusterId || !companyId) {
deps.print("Usage: cluster ensure-tenant <clusterId> <companyId>");
return 2;
}
const company = await deps.companies.getById(companyId);
if (!company) {
deps.print(`Company ${companyId} not found`);
return 1;
}
const slug = company.slug ?? deriveCompanySlug(company.name);
const tp = await deps.tenantPolicies.get(clusterId, companyId);
const result = await deps.driver.ensureTenant({
clusterConnectionId: clusterId,
company: { id: company.id, slug },
tenantPolicy: tp
? {
quota: tp.quota,
limitRange: tp.limitRange,
additionalAllowFqdns: tp.additionalAllowFqdns,
imageOverrides: tp.imageOverrides,
}
: null,
driverServiceAccount: {
name: process.env.PAPERCLIP_DRIVER_SA ?? "paperclip-driver",
namespace: process.env.PAPERCLIP_DRIVER_NAMESPACE ?? "paperclip-system",
},
controlPlane: {
topology: "cross-cluster",
namespaceLabels: {},
podLabels: {},
},
adapterAllowFqdns: [],
imagePullDockerConfigJson: null,
});
await deps.namespaceBindings.record({
clusterConnectionId: clusterId,
companyId,
namespaceName: result.namespace,
});
deps.print(`Provisioned namespace ${result.namespace} (cilium=${result.ciliumApplied})`);
return 0;
}
async function cmdDoctor(argv: string[], deps: ClusterCommandDeps): Promise<number> {
const [id] = argv;
if (!id) {
deps.print("Usage: cluster doctor <id>");
return 2;
}
const resolved = await deps.clusterConnections.resolve(id);
if (!resolved) {
deps.print(`Cluster connection ${id} not found`);
return 1;
}
deps.print(`Doctor report for cluster connection ${id} (${resolved.label}):`);
deps.print(` kind: ${resolved.kind}`);
deps.print(` cilium: ${resolved.capabilities.cilium}`);
deps.print(` storageClass: ${resolved.capabilities.storageClass}`);
deps.print(` archs: ${resolved.capabilities.architectures.join(", ")}`);
deps.print("");
deps.print(
`Apply the reference ClusterRole before first ensure-tenant:\n` +
` kubectl apply -f packages/adapters/kubernetes-execution/manifests/paperclip-tenant-manager-clusterrole.yaml`,
);
return 0;
}
+2
View File
@@ -25,6 +25,7 @@ import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
import { registerPluginCommands } from "./commands/client/plugin.js";
import { registerClientAuthCommands } from "./commands/client/auth.js";
import { registerClusterCommands } from "./commands/cluster-cmd.js";
import { cliVersion } from "./version.js";
const program = new Command();
@@ -152,6 +153,7 @@ registerSecretCommands(program);
registerWorktreeCommands(program);
registerEnvLabCommands(program);
registerPluginCommands(program);
registerClusterCommands(program);
const auth = program.command("auth").description("Authentication and bootstrap utilities");