feat: improve API keys and build workflows
This commit is contained in:
+89
-20
@@ -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
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user