feat: add CI deployment and live progress

This commit is contained in:
2026-09-05 12:09:16 +00:00 Unverified
parent 321f4e807a
commit aa02826dbb
27 changed files with 2711 additions and 180 deletions
+138 -5
View File
@@ -9,6 +9,8 @@ import { createKubernetesHttpLibrary } from "../lib/k8s-http";
import {
normalizeSession,
normalizeUser,
normalizeApiKey,
type ApiKeyRecord,
type AuthStore,
type KuberUser,
type NewKuberUser,
@@ -16,7 +18,7 @@ import {
type SessionRecord,
type UserUpdate,
} from "./auth";
import { isRole } from "./authorization";
import { isCapability, isRole, type Capability } from "./authorization";
const FIELD_MANAGER = "kuber-server";
export const KUBER_SYSTEM_NAMESPACE = "kuber-system";
@@ -81,7 +83,10 @@ function parseRoles(value: unknown): KuberUser["roles"] | undefined {
}
}
function isSecret(secret: SecretObject, type: "user" | "session"): boolean {
function isSecret(
secret: SecretObject,
type: "user" | "session" | "api-key",
): boolean {
return (
secret.apiVersion === "v1" &&
secret.kind === "Secret" &&
@@ -94,6 +99,24 @@ function isSecret(secret: SecretObject, type: "user" | "session"): boolean {
);
}
function parseCapabilities(value: unknown): Capability[] | undefined {
const decoded = decode(value);
if (!decoded) return;
try {
const capabilities: unknown = JSON.parse(decoded);
if (
!Array.isArray(capabilities) ||
capabilities.length === 0 ||
new Set(capabilities).size !== capabilities.length ||
!capabilities.every(isCapability)
)
return;
return capabilities;
} catch {
return;
}
}
function parseUser(
secret: SecretObject,
expectedUsername?: string,
@@ -160,6 +183,51 @@ function parseSession(secret: SecretObject): SessionRecord | undefined {
}
}
function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
if (!isSecret(secret, "api-key")) return;
const keys = [
"id",
"tokenHash",
"username",
"capabilities",
"workspace",
"expiresAt",
"disabled",
];
if (!secret.data || !hasOnlyKeys(secret.data, keys)) return;
const id = decode(secret.data.id);
const tokenHash = decode(secret.data.tokenHash);
const username = decode(secret.data.username);
const capabilities = parseCapabilities(secret.data.capabilities);
const workspace = decode(secret.data.workspace);
const expiresAt = decode(secret.data.expiresAt);
const disabled = decode(secret.data.disabled);
if (
!id ||
!tokenHash ||
!username ||
!capabilities ||
!expiresAt ||
(workspace !== "" && workspace === undefined) ||
(disabled !== "true" && disabled !== "false") ||
secret.metadata?.name !== objectName("api-key", tokenHash)
)
return;
try {
return normalizeApiKey({
id,
tokenHash,
username,
capabilities,
...(workspace && { workspace }),
expiresAt,
disabled: disabled === "true",
});
} catch {
return;
}
}
function isNotFound(error: unknown): boolean {
return Boolean(
error && typeof error === "object" && "code" in error && error.code === 404,
@@ -172,7 +240,10 @@ function createObjectApi(): KubernetesObjectApi {
else config.loadFromDefault();
const makeApiClient = config.makeApiClient.bind(config);
const httpLibrary = createKubernetesHttpLibrary();
const httpLibrary = createKubernetesHttpLibrary({
maxConcurrent: 4,
minIntervalMs: 0,
});
config.makeApiClient = ((apiClientType) => {
const client = makeApiClient(apiClientType) as unknown as {
api?: { configuration?: { httpApi?: typeof httpLibrary } };
@@ -205,7 +276,7 @@ export class KubernetesAuthStore implements AuthStore {
private async applySecret(
name: string,
type: "user" | "session",
type: "user" | "session" | "api-key",
stringData: Record<string, string>,
): Promise<void> {
await this.objects.patch(
@@ -228,7 +299,9 @@ export class KubernetesAuthStore implements AuthStore {
);
}
private async listSecrets(type: "user" | "session"): Promise<SecretObject[]> {
private async listSecrets(
type: "user" | "session" | "api-key",
): Promise<SecretObject[]> {
const result = await this.objects.list(
"v1",
"Secret",
@@ -309,6 +382,8 @@ export class KubernetesAuthStore implements AuthStore {
async deleteUser(username: string): Promise<boolean> {
await this.revokeUserSessions(username);
for (const key of await this.listApiKeys(username))
await this.deleteSecret(objectName("api-key", key.tokenHash));
return this.deleteSecret(objectName("user", username));
}
@@ -378,4 +453,62 @@ export class KubernetesAuthStore implements AuthStore {
}
return expired.length;
}
async getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined> {
if (!/^[a-f0-9]{64}$/.test(tokenHash)) return;
const key = (await this.listSecrets("api-key"))
.map(parseApiKey)
.find((item): item is ApiKeyRecord => item?.tokenHash === tokenHash);
if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return;
const user = await this.getUser(key.username);
if (!user || user.disabled) return;
return key;
}
async createApiKey(key: ApiKeyRecord): Promise<void> {
const normalized = normalizeApiKey(key);
const user = await this.getUser(normalized.username);
if (!user || user.disabled) throw new Error("API key user is not active");
await this.applySecret(
objectName("api-key", normalized.tokenHash),
"api-key",
{
id: normalized.id,
tokenHash: normalized.tokenHash,
username: normalized.username,
capabilities: JSON.stringify(normalized.capabilities),
workspace: normalized.workspace ?? "",
expiresAt: normalized.expiresAt,
disabled: String(Boolean(normalized.disabled)),
},
);
}
async listApiKeys(username: string): Promise<ApiKeyRecord[]> {
return (await this.listSecrets("api-key"))
.map(parseApiKey)
.filter((key): key is ApiKeyRecord => key?.username === username)
.sort((left, right) => left.id.localeCompare(right.id));
}
async revokeApiKey(username: string, id: string): Promise<boolean> {
const key = (await this.listApiKeys(username)).find(
(item) => item.id === id,
);
return key
? this.deleteSecret(objectName("api-key", key.tokenHash))
: false;
}
async deleteExpiredApiKeys(now = Date.now()): Promise<number> {
const expired = (await this.listSecrets("api-key"))
.map(parseApiKey)
.filter(
(key): key is ApiKeyRecord =>
key !== undefined && Date.parse(key.expiresAt) <= now,
);
for (const key of expired)
await this.deleteSecret(objectName("api-key", key.tokenHash));
return expired.length;
}
}