mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
feat: add kubernetes execution target provisioning
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0808b388ee
commit
4a7a45f960
87 files changed
+15005
-131
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user