feat: v2
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { listAuditEvents } from "../../command/audit";
|
||||
import { main } from "../../command/main";
|
||||
import { getOperation, listOperations } from "../../command/operations";
|
||||
import {
|
||||
addUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
revokeUserSessions,
|
||||
setUserDisabled,
|
||||
updateUser,
|
||||
} from "../../command/users";
|
||||
import type { ApiRequestInit } from "../../lib/api";
|
||||
|
||||
type Call = { path: string; init?: ApiRequestInit };
|
||||
|
||||
function requestReturning<T>(result: T, calls: Call[]) {
|
||||
return async <R>(path: string, init?: ApiRequestInit): Promise<R> => {
|
||||
calls.push({ path, init });
|
||||
return result as unknown as R;
|
||||
};
|
||||
}
|
||||
|
||||
const user = {
|
||||
username: "alice",
|
||||
roles: ["admin"],
|
||||
disabled: false,
|
||||
updatedAt: "2026-09-02T10:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("user administration commands", () => {
|
||||
test("lists and creates users through authenticated API routes", async () => {
|
||||
const calls: Call[] = [];
|
||||
expect(
|
||||
await listUsers(requestReturning({ items: [user] }, calls)),
|
||||
).toContain("alice");
|
||||
expect(
|
||||
await addUser(
|
||||
"alice",
|
||||
"secret",
|
||||
["admin"],
|
||||
requestReturning(user, calls),
|
||||
),
|
||||
).toContain("admin");
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ path: "/users", init: undefined },
|
||||
{
|
||||
path: "/users",
|
||||
init: {
|
||||
method: "POST",
|
||||
json: { username: "alice", password: "secret", roles: ["admin"] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("updates roles, passwords, and enabled state with PATCH", async () => {
|
||||
const calls: Call[] = [];
|
||||
const request = requestReturning(user, calls);
|
||||
await updateUser(
|
||||
"alice/example",
|
||||
{ roles: ["operator"], password: "new" },
|
||||
request,
|
||||
);
|
||||
await setUserDisabled("alice", true, request);
|
||||
await setUserDisabled("alice", false, request);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
path: "/users/alice%2Fexample",
|
||||
init: {
|
||||
method: "PATCH",
|
||||
json: { roles: ["operator"], password: "new" },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/users/alice",
|
||||
init: { method: "PATCH", json: { disabled: true } },
|
||||
},
|
||||
{
|
||||
path: "/users/alice",
|
||||
init: { method: "PATCH", json: { disabled: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("requires confirmation for deletion and supports session revocation", async () => {
|
||||
const calls: Call[] = [];
|
||||
const request = requestReturning(undefined, calls);
|
||||
expect(await deleteUser("alice", false, request)).toBe(
|
||||
"Deletion cancelled",
|
||||
);
|
||||
expect(await deleteUser("alice", true, request)).toBe("Deleted user alice");
|
||||
expect(
|
||||
await revokeUserSessions(
|
||||
"alice",
|
||||
requestReturning({ username: "alice", revoked: 2 }, calls),
|
||||
),
|
||||
).toBe("Revoked 2 sessions for alice");
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ path: "/users/alice", init: { method: "DELETE" } },
|
||||
{
|
||||
path: "/users/alice/sessions/revoke",
|
||||
init: { method: "POST" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const operation = {
|
||||
metadata: {
|
||||
name: "operation-1",
|
||||
creationTimestamp: "2026-09-02T10:00:00.000Z",
|
||||
},
|
||||
spec: { workspaceId: "team/shop", action: "restart" },
|
||||
status: { state: "succeeded", result: { deployments: ["web"] } },
|
||||
};
|
||||
|
||||
describe("operations and audit commands", () => {
|
||||
test("lists filtered operations and gets operation details", async () => {
|
||||
const calls: Call[] = [];
|
||||
const listing = await listOperations(
|
||||
"team/shop",
|
||||
requestReturning({ items: [operation] }, calls),
|
||||
);
|
||||
const detail = await getOperation(
|
||||
"operation/1",
|
||||
requestReturning(operation, calls),
|
||||
);
|
||||
|
||||
expect(listing).toContain("restart");
|
||||
expect(detail).toContain('Result: {"deployments":["web"]}');
|
||||
expect(calls).toEqual([
|
||||
{ path: "/operations?workspaceId=team%2Fshop", init: undefined },
|
||||
{ path: "/operations/operation%2F1", init: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists audit events with an optional workspace filter", async () => {
|
||||
const calls: Call[] = [];
|
||||
const output = await listAuditEvents(
|
||||
"team/shop",
|
||||
requestReturning(
|
||||
{
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: "audit-1",
|
||||
creationTimestamp: "2026-09-02T10:00:00.000Z",
|
||||
},
|
||||
spec: {
|
||||
actor: { username: "alice" },
|
||||
action: "workspace.restart",
|
||||
workspaceId: "team/shop",
|
||||
outcome: "success",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
calls,
|
||||
),
|
||||
);
|
||||
|
||||
expect(output).toContain("alice");
|
||||
expect(output).toContain("workspace.restart");
|
||||
expect(calls).toEqual([
|
||||
{ path: "/audit?workspaceId=team%2Fshop", init: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("registers administration command groups", async () => {
|
||||
const subCommands = await Promise.resolve(main.subCommands);
|
||||
expect(Object.keys(subCommands ?? {})).toEqual(
|
||||
expect.arrayContaining(["users", "operations", "audit"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getDatabaseCredentials } from "../../command/db";
|
||||
import { runDown } from "../../command/down";
|
||||
import { runPs } from "../../command/ps";
|
||||
import { runRestart } from "../../command/restart";
|
||||
import { runRollback } from "../../command/rollback";
|
||||
import { getRemoteS3Credentials } from "../../command/s3";
|
||||
import { runStop } from "../../command/stop";
|
||||
import type { ApiRequestInit } from "../../lib/api";
|
||||
|
||||
type Call = { path: string; init?: ApiRequestInit };
|
||||
|
||||
function requestReturning<T>(result: T, calls: Call[]) {
|
||||
return async <R>(path: string, init?: ApiRequestInit): Promise<R> => {
|
||||
calls.push({ path, init });
|
||||
return result as unknown as R;
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace API command runners", () => {
|
||||
test("ps requests structured status and renders it locally", async () => {
|
||||
const calls: Call[] = [];
|
||||
const output = await runPs(
|
||||
"shop/demo",
|
||||
true,
|
||||
requestReturning(
|
||||
[
|
||||
{
|
||||
namespace: "shop-demo",
|
||||
roots: [
|
||||
{
|
||||
id: "Deployment/web",
|
||||
status: { label: "1/1", level: "good" as const },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
calls,
|
||||
),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
path: "/workspaces/shop%2Fdemo/status?includeIdle=true",
|
||||
init: undefined,
|
||||
},
|
||||
]);
|
||||
expect(output).toContain("Deployment/web");
|
||||
expect(output).toContain("\u001b[42m");
|
||||
});
|
||||
|
||||
test("stop and restart use lifecycle actions with optional targeting", async () => {
|
||||
const calls: Call[] = [];
|
||||
const request = requestReturning({ deployments: ["web"] }, calls);
|
||||
|
||||
await runStop("shop", request);
|
||||
await runRestart("shop", undefined, request);
|
||||
await runRestart("shop", "worker", request);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
path: "/workspaces/shop/lifecycle",
|
||||
init: { method: "POST", json: { action: "stop" } },
|
||||
},
|
||||
{
|
||||
path: "/workspaces/shop/lifecycle",
|
||||
init: { method: "POST", json: { action: "restart" } },
|
||||
},
|
||||
{
|
||||
path: "/workspaces/shop/lifecycle",
|
||||
init: {
|
||||
method: "POST",
|
||||
json: { action: "restart", services: ["worker"] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("rollback and down delegate complete server-side operations", async () => {
|
||||
const calls: Call[] = [];
|
||||
await runRollback(
|
||||
"shop",
|
||||
"web",
|
||||
45_000,
|
||||
requestReturning({ deployments: ["web"] }, calls),
|
||||
);
|
||||
await runDown(
|
||||
"shop",
|
||||
true,
|
||||
requestReturning({ full: true, retained: [], delete: [] }, calls),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
path: "/workspaces/shop/rollback",
|
||||
init: {
|
||||
method: "POST",
|
||||
json: { services: ["web"], timeoutMs: 45_000 },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/workspaces/shop/down",
|
||||
init: { method: "POST", json: { full: true } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("database and S3 credential requests send resolved local claims", async () => {
|
||||
const calls: Call[] = [];
|
||||
const databaseClaim = {
|
||||
service: "web",
|
||||
username: "app",
|
||||
database: "app",
|
||||
secretName: "postgres-app",
|
||||
};
|
||||
const s3Claim = { service: "web", key: "assets", bucket: "assets" };
|
||||
|
||||
await getDatabaseCredentials(
|
||||
"shop",
|
||||
databaseClaim,
|
||||
requestReturning({ username: "app", password: "secret" }, calls),
|
||||
);
|
||||
await getRemoteS3Credentials(
|
||||
"shop",
|
||||
s3Claim,
|
||||
requestReturning({ AWS_ACCESS_KEY_ID: "key" }, calls),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
path: "/workspaces/shop/databases/credentials",
|
||||
init: { method: "POST", json: { claim: databaseClaim } },
|
||||
},
|
||||
{
|
||||
path: "/workspaces/shop/storage/credentials",
|
||||
init: { method: "POST", json: { claim: s3Claim } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { runExec, type ExecSessionOpener } from "../../command/exec";
|
||||
import type { ExecApiSession, ExecOutputFrame } from "../../lib/exec-api";
|
||||
|
||||
function fakeSession(...frames: ExecOutputFrame[]): ExecApiSession {
|
||||
return {
|
||||
sendStdin() {},
|
||||
resize() {},
|
||||
close() {},
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* frames;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("exec API command runner", () => {
|
||||
test("opens the authenticated workspace endpoint and returns remote status", async () => {
|
||||
let call: Parameters<ExecSessionOpener> | undefined;
|
||||
const opener: ExecSessionOpener = async (...args) => {
|
||||
call = args;
|
||||
return fakeSession({ type: "exit", exitCode: 23 });
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const result = await runExec(
|
||||
"shop/demo",
|
||||
"api",
|
||||
["sh", "-c", "exit 23"],
|
||||
controller.signal,
|
||||
opener,
|
||||
);
|
||||
|
||||
expect(result).toBe(23);
|
||||
expect(call?.[0]).toBe("shop/demo");
|
||||
expect(call?.[1]).toMatchObject({
|
||||
deployment: "api",
|
||||
command: ["sh", "-c", "exit 23"],
|
||||
tty: false,
|
||||
});
|
||||
expect(call?.[2]).toBe(controller.signal);
|
||||
});
|
||||
|
||||
test("validates required arguments before opening a connection", async () => {
|
||||
let calls = 0;
|
||||
const opener: ExecSessionOpener = async () => {
|
||||
calls += 1;
|
||||
return fakeSession();
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
await expect(runExec("shop", "", ["sh"], signal, opener)).rejects.toThrow(
|
||||
"Deployment name",
|
||||
);
|
||||
await expect(runExec("shop", "api", [], signal, opener)).rejects.toThrow(
|
||||
"Command is required",
|
||||
);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
runLogs,
|
||||
type LogApiEvent,
|
||||
type LogEventWriter,
|
||||
type LogsApiStream,
|
||||
} from "../../command/logs";
|
||||
|
||||
describe("logs API command runner", () => {
|
||||
test("uses the authenticated workspace NDJSON route and preserves prefixes", async () => {
|
||||
const calls: Array<{
|
||||
path: string;
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
}> = [];
|
||||
const events: LogApiEvent[] = [
|
||||
{ type: "heartbeat", timestamp: "now" },
|
||||
{
|
||||
type: "log",
|
||||
targetName: "web",
|
||||
pod: "web-1",
|
||||
container: "web",
|
||||
message: "ready",
|
||||
},
|
||||
{
|
||||
type: "error",
|
||||
pod: "web-1",
|
||||
container: "web",
|
||||
message: "disconnected",
|
||||
retryable: true,
|
||||
},
|
||||
];
|
||||
const stream: LogsApiStream = async function* (path, init, options) {
|
||||
calls.push({
|
||||
path,
|
||||
signal: init?.signal ?? undefined,
|
||||
timeout: options?.timeoutMs,
|
||||
});
|
||||
yield* events as never[];
|
||||
};
|
||||
const output: Parameters<LogEventWriter>[] = [];
|
||||
const controller = new AbortController();
|
||||
await runLogs(
|
||||
"shop/demo",
|
||||
"web api",
|
||||
true,
|
||||
controller.signal,
|
||||
stream,
|
||||
(...entry) => output.push(entry),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
path: "/workspaces/shop%2Fdemo/logs?service=web+api&follow=true",
|
||||
signal: controller.signal,
|
||||
timeout: 0,
|
||||
},
|
||||
]);
|
||||
expect(output).toEqual([
|
||||
["web", "ready", false],
|
||||
["web api", "disconnected", true],
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits follow for finite collection and passes cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal: AbortSignal | null | undefined;
|
||||
const stream: LogsApiStream = async function* (path, init, options) {
|
||||
expect(path).toBe("/workspaces/shop/logs");
|
||||
expect(options?.timeoutMs).toBeUndefined();
|
||||
receivedSignal = init?.signal;
|
||||
yield* [];
|
||||
};
|
||||
await runLogs(
|
||||
"shop",
|
||||
undefined,
|
||||
false,
|
||||
controller.signal,
|
||||
stream,
|
||||
() => {},
|
||||
);
|
||||
expect(receivedSignal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { login, logout, whoami } from "../../command/auth";
|
||||
import { db } from "../../command/db";
|
||||
import { down } from "../../command/down";
|
||||
import { exec } from "../../command/exec";
|
||||
@@ -23,6 +24,9 @@ type Command = {
|
||||
describe("CLI command definitions", () => {
|
||||
const commands = [
|
||||
["up", "Create and start deployments", up],
|
||||
["login", "Log in to kuber.astrxl.dev", login],
|
||||
["logout", "Log out of kuber.astrxl.dev", logout],
|
||||
["whoami", "Show the current kuber user", whoami],
|
||||
["start", "Start deployments without rebuilding images", start],
|
||||
["stop", "Scale managed deployments to zero", stop],
|
||||
["restart", "Roll out a restart for managed deployments", restart],
|
||||
@@ -30,7 +34,8 @@ describe("CLI command definitions", () => {
|
||||
["s3", "Inspect managed S3 storage", s3],
|
||||
["down", "Delete managed resources except ingress and PVCs", down],
|
||||
["ps", "List deployments", ps],
|
||||
["logs", "Show deployment logs", logs], ["exec", "Execute a command inside a deployment pod", exec],
|
||||
["logs", "Show deployment logs", logs],
|
||||
["exec", "Execute a command inside a deployment pod", exec],
|
||||
["export", "Write rendered manifests to a YAML file", exportCommand],
|
||||
["db", "Inspect managed postgres databases", db],
|
||||
] as const;
|
||||
@@ -74,6 +79,9 @@ describe("CLI command definitions", () => {
|
||||
expect((main as Command).args?.config).toMatchObject({
|
||||
type: "string",
|
||||
});
|
||||
expect((login as Command).args?.persist).toMatchObject({
|
||||
type: "boolean",
|
||||
});
|
||||
});
|
||||
|
||||
test("defines both database inspection subcommands", () => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ApiRequestInit } from "../../lib/api";
|
||||
import { KuberApiError } from "../../lib/api";
|
||||
import type { ApiRequester } from "../../lib/build";
|
||||
import {
|
||||
ensureWorkspace,
|
||||
reconcileResources,
|
||||
workspaceAdoptionRoute,
|
||||
} from "../../command/up";
|
||||
import { workspaceManifestDigest } from "../../lib/workspace";
|
||||
|
||||
const manifest = { version: 1 as const, files: [] };
|
||||
const snapshot = {
|
||||
manifest,
|
||||
digest: workspaceManifestDigest(manifest),
|
||||
blobs: [],
|
||||
};
|
||||
|
||||
describe("up API pipeline", () => {
|
||||
test("creates workspace metadata without embedding source blobs", async () => {
|
||||
const calls: Array<{ path: string; init?: ApiRequestInit }> = [];
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
calls.push({ path, init });
|
||||
if (!init) throw new KuberApiError("missing", 404);
|
||||
return {
|
||||
metadata: { name: "shop", uid: "uid", resourceVersion: "1" },
|
||||
} as T;
|
||||
};
|
||||
await ensureWorkspace(
|
||||
"shop",
|
||||
{ services: { web: { image: "nginx" } } },
|
||||
snapshot,
|
||||
request,
|
||||
);
|
||||
|
||||
expect(calls.map(({ path }) => path)).toEqual([
|
||||
"/workspaces/shop",
|
||||
"/workspaces",
|
||||
]);
|
||||
const body = calls[1]!.init?.json as Record<string, unknown>;
|
||||
expect(body).toMatchObject({
|
||||
id: "shop",
|
||||
source: { uri: `cas://${snapshot.digest}`, digest: snapshot.digest },
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain('"blob"');
|
||||
expect(JSON.stringify(body)).not.toContain('"files"');
|
||||
});
|
||||
|
||||
test("updates workspace metadata with the current resource-version ETag", async () => {
|
||||
const calls: Array<{ path: string; init?: ApiRequestInit }> = [];
|
||||
const request: ApiRequester = async <T>(
|
||||
path: string,
|
||||
init?: ApiRequestInit,
|
||||
) => {
|
||||
calls.push({ path, init });
|
||||
return {
|
||||
metadata: {
|
||||
name: "shop",
|
||||
uid: "uid",
|
||||
resourceVersion: init ? "8" : "7",
|
||||
},
|
||||
} as T;
|
||||
};
|
||||
await ensureWorkspace("shop", { services: {} }, snapshot, request);
|
||||
expect(calls[1]?.init?.method).toBe("PUT");
|
||||
expect(new Headers(calls[1]?.init?.headers).get("if-match")).toBe('"7"');
|
||||
});
|
||||
|
||||
test("plans, applies, runs the hook, waits, then deletes stale identities", async () => {
|
||||
const order: string[] = [];
|
||||
const desired = [
|
||||
{ apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "web" } },
|
||||
];
|
||||
const stale = [
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
name: "old",
|
||||
uid: "secret-uid",
|
||||
workspaceUid: "workspace-uid",
|
||||
},
|
||||
];
|
||||
const request: ApiRequester = async <T>(path: string) => {
|
||||
order.push(path.split("/").at(-1)!);
|
||||
if (path.endsWith("/plan")) return { desired, stale } as T;
|
||||
return {} as T;
|
||||
};
|
||||
|
||||
await reconcileResources(
|
||||
"shop",
|
||||
desired,
|
||||
42_000,
|
||||
() => {
|
||||
order.push("hook");
|
||||
},
|
||||
request,
|
||||
);
|
||||
expect(order).toEqual(["plan", "apply", "hook", "wait", "delete"]);
|
||||
});
|
||||
|
||||
test("fails closed with the precise missing adoption route", async () => {
|
||||
const request: ApiRequester = async <T>(path: string) => {
|
||||
if (path.endsWith("/plan"))
|
||||
throw new Error(
|
||||
"Namespace shop is external; refusing workspace mutation",
|
||||
);
|
||||
return {} as T;
|
||||
};
|
||||
await expect(
|
||||
reconcileResources("shop", [], 1, undefined, request),
|
||||
).rejects.toThrow(`POST ${workspaceAdoptionRoute("shop")}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user