feat: improve API keys and build workflows

This commit is contained in:
2026-10-04 20:05:13 +00:00 Unverified
parent 62f2362a2e
commit e4623efe86
27 changed files with 2080 additions and 235 deletions
+89 -20
View File
@@ -1,5 +1,5 @@
import { defineCommand } from "citty";
import { Listr, type ListrTaskWrapper } from "listr2";
import { Listr, ListrErrorTypes, type ListrTaskWrapper } from "listr2";
import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import type { ComposeSpecification } from "../schema/docker.d";
@@ -765,34 +765,103 @@ export async function runUp(
},
{
title: "Build images",
rendererOptions: { outputBar: 10, persistentOutput: true },
rendererOptions: { bottomBar: Infinity, persistentOutput: true },
enabled: async () =>
build &&
Object.values((await compose()).services ?? {}).some(
(service) => service.build,
),
task: async (taskCtx, task) => {
const result = await buildServices(
project,
taskCtx.compose!,
cwd,
{
progress: (message) => {
task.output = message;
},
stream: task.stdout(),
},
{
...config,
request,
snapshot: taskCtx.snapshot,
workspaceRoot: taskCtx.workspaceRoot,
signal: task.signal,
},
const services = Object.entries(taskCtx.compose!.services ?? {})
.filter(([, service]) => service.build)
.map(([name]) => name);
const children = new Map<
string,
ListrTaskWrapper<UpContext, any, any>
>();
const pending = new Map<string, (error?: unknown) => void>();
const completed = new Map<string, Promise<unknown>>(
services.map((name) => [
name,
new Promise((resolve) => pending.set(name, resolve)),
]),
);
let result: Awaited<ReturnType<typeof buildServices>> | undefined;
let operationFailure: Error | undefined;
let operation: Promise<void> | undefined;
const start = () => {
operation ??= buildServices(
project,
taskCtx.compose!,
cwd,
{
progress: (message) => {
task.output = message;
},
service: (name) => {
let stream: ReturnType<ListrTaskWrapper<UpContext, any, any>["stdout"]> | undefined;
return {
progress: (message) => {
const child = children.get(name);
if (child && child.output !== message) child.output = message;
},
get stream() {
const child = children.get(name);
if (child) stream ??= child.stdout();
return stream;
},
};
},
settled: (name, error) => {
pending.get(name)?.(error);
pending.delete(name);
},
},
{
...config,
request,
snapshot: taskCtx.snapshot,
workspaceRoot: taskCtx.workspaceRoot,
signal: task.signal,
},
).then(
(value) => {
result = value;
},
(error: unknown) => {
const failure =
error instanceof Error ? error : new Error(String(error));
operationFailure = failure;
task.output = failure.stack ?? failure.message;
task.report(failure, ListrErrorTypes.HAS_FAILED);
for (const resolve of pending.values()) resolve(failure);
pending.clear();
},
);
return operation;
};
await task
.newListr<UpContext>(
services.map((name) => ({
title: `Build ${name}`,
rendererOptions: { outputBar: 10, persistentOutput: true },
task: async (_ctx, child) => {
children.set(name, child);
child.output = "Build queued";
if (children.size === services.length) start();
const error = await completed.get(name);
if (error) throw error;
child.output = "done";
},
})),
{ concurrent: true, exitOnError: false },
)
.run();
await operation;
if (!result) throw operationFailure ?? new Error("Build images failed");
taskCtx.buildImages = result.images;
await config.postBuild?.(result, await getHookContext());
task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}`;
task.title = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}`;
},
},
{
+64 -19
View File
@@ -61,7 +61,9 @@ function parseCapabilities(
"platform:adopt",
]);
if (!capabilities.length || capabilities.some((item) => !allowed.has(item)))
throw new Error("Provide at least one valid capability");
throw new Error(
"Provide valid capabilities (for example: --capabilities kubernetes:read,kubernetes:write; choices: kubernetes:read, kubernetes:write, kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)",
);
return [...new Set(capabilities)] as CreateApiKeyRequest["capabilities"];
}
@@ -202,22 +204,37 @@ function renderApiKeys(keys: ApiKey[]): string {
return toTable(
keys.map((key) => ({
id: key.id,
username: key.username,
capabilities: key.capabilities.join(","),
workspace: key.workspace ?? "",
expires: key.expiresAt,
expires: key.expiresAt ?? "never",
disabled: key.disabled ? "yes" : "no",
})),
);
}
export async function listApiKeys(
username: string,
username?: string,
request: UsersApiRequest = apiRequest,
): Promise<string> {
const response = await request<ListApiKeysResponse>(
`/users/${encodeURIComponent(username)}/keys`,
const usernames = username
? [username]
: (await request<ListUsersResponse>("/users")).items.map(
(user) => user.username,
);
const results = await Promise.all(
usernames.map((name) =>
request<ListApiKeysResponse>(`/users/${encodeURIComponent(name)}/keys`),
),
);
return renderApiKeys(
results
.flatMap((result) => result.items)
.sort(
(a, b) =>
a.username.localeCompare(b.username) || a.id.localeCompare(b.id),
),
);
return renderApiKeys(response.items);
}
export async function createApiKey(
@@ -225,6 +242,11 @@ export async function createApiKey(
body: CreateApiKeyRequest,
request: UsersApiRequest = apiRequest,
): Promise<CreateApiKeyResponse> {
const user = await request<UserResponse>(
`/users/${encodeURIComponent(username)}`,
);
if (user.disabled)
throw new Error(`Cannot create API key: user '${username}' is inactive`);
return request<CreateApiKeyResponse>(
`/users/${encodeURIComponent(username)}/keys`,
{ method: "POST", json: body },
@@ -318,39 +340,62 @@ const keys = defineCommand({
meta: { name: "keys", description: "Manage user API keys" },
subCommands: {
ls: defineCommand({
meta: { name: "ls", description: "List a user's API keys" },
meta: {
name: "ls",
description: "List all API keys (optional positional username filters the results)",
},
async run({ args }) {
console.log(await listApiKeys(requireUsername(args._[0])));
console.log(await listApiKeys(args._[0]));
},
}),
create: defineCommand({
meta: { name: "create", description: "Create an API key" },
meta: {
name: "create",
description:
"Create an API key for a user (e.g. kuber users keys create alice --capabilities kubernetes:read --expires-days none)",
},
args: {
username: {
type: "positional",
required: true,
description: "Username to own the API key",
},
capabilities: {
type: "string",
required: true,
description: "Comma-separated capabilities",
description:
"Comma-separated capabilities, e.g. kubernetes:read,kubernetes:write (also kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)",
},
workspace: {
type: "string",
description: "Restrict the key to a workspace",
},
expiresDays: {
"expires-days": {
type: "string",
default: "90",
description: "Expiry in days (1-365)",
description: "Expiry in days (1-365), or none for a non-expiring key",
},
},
async run({ args }) {
const days = Number(args.expiresDays);
if (!Number.isSafeInteger(days) || days < 1 || days > 365)
throw new Error("--expires-days must be an integer from 1 to 365");
const key = await createApiKey(requireUsername(args._[0]), {
const days =
args["expires-days"] === "none"
? undefined
: Number(args["expires-days"]);
if (
days !== undefined &&
(!Number.isSafeInteger(days) || days < 1 || days > 365)
)
throw new Error(
"--expires-days must be an integer from 1 to 365, or none",
);
const key = await createApiKey(requireUsername(args.username), {
capabilities: parseCapabilities(args.capabilities),
...(args.workspace && { workspace: args.workspace }),
expiresAt: new Date(
Date.now() + days * 24 * 60 * 60 * 1000,
).toISOString(),
...(days !== undefined && {
expiresAt: new Date(
Date.now() + days * 24 * 60 * 60 * 1000,
).toISOString(),
}),
});
console.log(
"Store this API key securely now. It will not be shown again:",