diff --git a/src/lib/components/layout/ClusterSwitcher.svelte b/src/lib/components/layout/ClusterSwitcher.svelte index 0519f453..92f1e451 100644 --- a/src/lib/components/layout/ClusterSwitcher.svelte +++ b/src/lib/components/layout/ClusterSwitcher.svelte @@ -23,10 +23,16 @@ const selectedCluster = $derived( availableClusters.find((cluster) => cluster.id === currentCluster) ?? null ); + let selectingClusterId = $state(null); - function selectCluster(clusterId: string) { - if (clusterId === currentCluster) return; - clusterStore.setCluster(clusterId); + async function selectCluster(clusterId: string) { + if (clusterId === currentCluster || selectingClusterId) return; + selectingClusterId = clusterId; + try { + await clusterStore.setCluster(clusterId); + } finally { + selectingClusterId = null; + } } @@ -93,6 +99,7 @@ onSelect={() => selectCluster(cluster.id)} class={cn( 'mb-0.5 cursor-pointer gap-3 rounded-xl px-3 py-3 transition-colors last:mb-0', + selectingClusterId ? 'pointer-events-none opacity-60' : '', cluster.id === currentCluster ? 'bg-primary/5 hover:bg-primary/10' : 'hover:bg-accent/50' @@ -133,7 +140,9 @@ {eventsStore.clusterUnreadCounts[cluster.id]} {/if} - {#if cluster.id === currentCluster} + {#if selectingClusterId === cluster.id} + + {:else if cluster.id === currentCluster} {/if} diff --git a/src/lib/components/wizards/ResourceWizard.svelte b/src/lib/components/wizards/ResourceWizard.svelte index 8e327a5b..cd4864dc 100644 --- a/src/lib/components/wizards/ResourceWizard.svelte +++ b/src/lib/components/wizards/ResourceWizard.svelte @@ -87,21 +87,21 @@ const values: Record = {}; template.fields.forEach((field) => { - const path = field.path.split('.'); - let current = parsed; - for (let i = 0; i < path.length; i++) { - if (!current) break; - if (i === path.length - 1) { - values[field.name] = coerceFieldValue(field, current[path[i]]); - } else { - current = current[path[i]] as Record; - } - } + values[field.name] = coerceFieldValue(field, getValueAtPath(parsed, field.path)); // Apply default namespace if (field.name === 'namespace' && defaultNamespace) { values[field.name] = defaultNamespace; } + + if (field.virtual) { + const manifestValue = inferVirtualFieldValue(field, parsed); + if (manifestValue !== undefined) { + values[field.name] = manifestValue; + } else if (values[field.name] === undefined && field.default !== undefined) { + values[field.name] = field.default; + } + } }); formValues = values; hasInitializedFormValues = true; @@ -128,9 +128,28 @@ template.fields.forEach((field) => { if (field.virtual) return; + if (!shouldShowField(field)) { + const visibleFieldWithSamePath = template.fields.some( + (candidate) => + candidate !== field && + !candidate.virtual && + candidate.path === field.path && + shouldShowField(candidate) + ); + if (!visibleFieldWithSamePath) { + doc.deleteIn(field.path.split('.')); + } + return; + } + const value = coerceFieldValue(field, formValues[field.name]); const path = field.path.split('.'); + if (field.name === 'verifyMode' && value === '') { + doc.deleteIn(path.slice(0, -1)); + return; + } + if (field.type === 'number' && value === undefined) { doc.deleteIn(path); return; @@ -153,18 +172,17 @@ const values: Record = { ...formValues }; template.fields.forEach((field) => { - if (field.virtual) return; - - const path = field.path.split('.'); - let current = parsed; - for (let i = 0; i < path.length; i++) { - if (!current) break; - if (i === path.length - 1) { - values[field.name] = coerceFieldValue(field, current[path[i]]); - } else { - current = current[path[i]] as Record; + if (field.virtual) { + const manifestValue = inferVirtualFieldValue(field, parsed); + if (manifestValue !== undefined) { + values[field.name] = manifestValue; + } else if (values[field.name] === undefined && field.default !== undefined) { + values[field.name] = field.default; } + return; } + + values[field.name] = coerceFieldValue(field, getValueAtPath(parsed, field.path)); }); formValues = values; } catch (err) { @@ -176,6 +194,44 @@ } } + function getValueAtPath(source: Record, path: string): unknown { + const segments = path.split('.'); + let current: unknown = source; + + for (const segment of segments) { + if (!current || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[segment]; + } + + return current; + } + + function hasPopulatedValue(value: unknown): boolean { + return ( + value !== undefined && + value !== null && + value !== '' && + (!Array.isArray(value) || value.length > 0) + ); + } + + function inferVirtualFieldValue( + field: TemplateField, + source: Record + ): string | undefined { + for (const candidate of template.fields) { + if (candidate.virtual || candidate.showIf?.field !== field.name) continue; + if (!hasPopulatedValue(getValueAtPath(source, candidate.path))) continue; + + const showIfValue = candidate.showIf.value; + return Array.isArray(showIfValue) ? showIfValue[0] : showIfValue; + } + + return undefined; + } + function coerceFieldValue(field: TemplateField, value: unknown): unknown { if (field.type !== 'number') { return value; @@ -431,10 +487,61 @@ } }); + const resourceConflict = validateHelmReleaseResourceValues(); + if (resourceConflict) { + for (const fieldName of [ + 'resourceLimitsCpu', + 'resourceLimitsMemory', + 'resourceRequestsCpu', + 'resourceRequestsMemory' + ]) { + if (formValues[fieldName]) { + errors[fieldName] = resourceConflict; + } + } + } + validationErrors = errors; return Object.keys(errors).length === 0; } + function validateHelmReleaseResourceValues(): string | null { + if (template.kind !== 'HelmRelease') return null; + + const structuredResourceFields = [ + 'resourceLimitsCpu', + 'resourceLimitsMemory', + 'resourceRequestsCpu', + 'resourceRequestsMemory' + ]; + if (!structuredResourceFields.some((fieldName) => Boolean(formValues[fieldName]))) { + return null; + } + + const values = formValues.values; + if (!values) return null; + + try { + const parsedValues = + typeof values === 'string' + ? (parse(values) as Record | null) + : (values as Record); + if ( + parsedValues && + typeof parsedValues === 'object' && + !Array.isArray(parsedValues) && + 'resources' in parsedValues + ) { + return 'Remove resources from Values before using structured resource fields.'; + } + } catch (err) { + logger.warn(err, 'Failed to parse HelmRelease values while checking resource conflicts'); + return null; + } + + return null; + } + // Check if form is valid (derived) const isFormValid = $derived.by(() => { if (yamlError) return false; // Invalid if YAML has syntax errors diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 126a9abb..5ccab4be 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,805 +1,7 @@ -import { logger } from './logger.js'; -import bcrypt from 'bcryptjs'; -import { getDb, getDbSync, type Account, type NewUser, type User } from './db/index.js'; -import { users, sessions, passwordHistory, accounts } from './db/schema.js'; -import { getPaginatedItems, sanitizeSearchInput } from './db/utils.js'; -import { eq, and, lte, sql, or, inArray, desc } from 'drizzle-orm'; -import { randomBytes, randomInt } from 'node:crypto'; -import { bindUserToDefaultPolicies } from './rbac-defaults.js'; -import { passwordSchema } from '../utils/validation.js'; -import * as k8s from '@kubernetes/client-node'; -import { readFileSync, unlinkSync } from 'node:fs'; -import { loginAttemptsTotal, sessionsCleanedUpTotal } from './metrics.js'; - -// In-cluster configuration paths -const IN_CLUSTER_NAMESPACE_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/namespace'; - -const SALT_ROUNDS = 12; -export const SESSION_DURATION_DAYS = 2; -const PASSWORD_HISTORY_LIMIT = 5; - -type Tx = Parameters>['transaction']>[0]>[0]; - -function prunePasswordHistoryInTx(tx: Tx, userId: string): void { - const rows = tx - .select({ id: passwordHistory.id }) - .from(passwordHistory) - .where(eq(passwordHistory.userId, userId)) - .orderBy(desc(passwordHistory.createdAtMs)) - .all(); - - if (rows.length > PASSWORD_HISTORY_LIMIT) { - const toDelete = rows.slice(PASSWORD_HISTORY_LIMIT).map((r) => r.id); - tx.delete(passwordHistory).where(inArray(passwordHistory.id, toDelete)).run(); - } -} -const ADMIN_SECRET_NAME = 'gyre-initial-admin-secret'; -const _maxSessionsEnv = parseInt(process.env.MAX_SESSIONS_PER_USER ?? '', 10); -export const MAX_SESSIONS_PER_USER = - Number.isFinite(_maxSessionsEnv) && _maxSessionsEnv > 0 ? _maxSessionsEnv : 10; - -function warnIfWeakAdminPassword(password: string): void { - const result = passwordSchema.safeParse(password); - if (!result.success) { - logger.warn( - { issues: result.error.issues.map((i) => i.message) }, - '⚠️ ADMIN_PASSWORD does not meet strength requirements.' - ); - } -} - -// Pre-computed dummy hash for constant-time comparisons (prevents timing-based enumeration) -const DUMMY_HASH: Promise = bcrypt.hash('__dummy_password_for_timing__', SALT_ROUNDS); - -// Tracks whether a setup token file has been written and not yet consumed -let pendingSetupCleanup = false; - -// Path to the local-dev setup token file; cleared after first successful login -let setupTokenFilePath: string | null = null; - -/** - * Register the path of the setup token file written during local-dev first-run. - * The file is removed automatically after the first successful admin login. - */ -export function setSetupTokenFile(filePath: string): void { - setupTokenFilePath = filePath; -} - -export function cleanupSetupTokenFile(): void { - if (!pendingSetupCleanup || setupTokenFilePath === null) return; - const filePath = setupTokenFilePath; - try { - unlinkSync(filePath); - setupTokenFilePath = null; - pendingSetupCleanup = false; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - setupTokenFilePath = null; - pendingSetupCleanup = false; - } else { - logger.error( - { err, filePath }, - '[Auth] Failed to remove setup token file; manual removal may be required' - ); - } - } -} - -// For in-cluster mode: password read from K8s secret (stored hashed) -let inClusterAdminPasswordHash: string | null = null; -let inClusterFirstLoginDone = false; -/** - * Normalize username to a canonical form (lowercase, trimmed) - */ -export function normalizeUsername(username: string): string { - return username.toLowerCase().trim(); -} - -/** - * Generate a strong random password -... - * Format: 3 random words + 3 random digits + 1 special char - * Easy to read but secure (e.g., "BlueTiger7Sky#42") - */ -export function generateStrongPassword(): string { - const words = [ - 'Alpha', - 'Beta', - 'Gamma', - 'Delta', - 'Echo', - 'Fox', - 'Hawk', - 'Lion', - 'Bear', - 'Wolf', - 'Blue', - 'Red', - 'Green', - 'Gold', - 'Iron', - 'Steel', - 'Fire', - 'Ice', - 'Storm', - 'Thunder', - 'Cloud', - 'Sky', - 'Star', - 'Moon', - 'Sun', - 'Wave', - 'Ocean', - 'Mountain', - 'Forest', - 'River' - ]; - const specials = '!@#$%^&*'; - - const word1 = words[randomInt(0, words.length)]; - const word2 = words[randomInt(0, words.length)]; - const word3 = words[randomInt(0, words.length)]; - const digits = randomInt(10, 100).toString(); - const special = specials[randomInt(0, specials.length)]; - - return `${word1}${word2}${digits}${word3}${special}`; -} - -/** - * @deprecated The generated admin password is no longer stored in memory. - * It is returned directly from createDefaultAdminIfNeeded() and written to a restricted temp file. - */ -export function getGeneratedAdminPassword(): string | null { - return null; -} - -// Password hashing -export async function hashPassword(password: string): Promise { - return bcrypt.hash(password, SALT_ROUNDS); -} - -export async function verifyPassword(password: string, hash: string): Promise { - return bcrypt.compare(password, hash); -} - -// Session ID generation -export function generateSessionId(): string { - return randomBytes(32).toString('hex'); -} - -export function generateUserId(): string { - return randomBytes(16).toString('hex'); -} - -export async function getCredentialPasswordHash(userId: string): Promise { - const db = await getDb(); - const user = await db.query.users.findFirst({ - where: eq(users.id, userId) - }); - - if (!user) { - return null; - } - - if (normalizeUsername(user.username) === 'admin' && isInClusterMode()) { - return null; - } - - const account = await db.query.accounts.findFirst({ - where: and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')) - }); - - return account?.password || null; -} - -export async function getCredentialAccount(userId: string): Promise { - const db = await getDb(); - const account = await db.query.accounts.findFirst({ - where: and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')) - }); - - return account || null; -} - -export async function hasManagedPassword(userId: string): Promise { - return Boolean(await getCredentialPasswordHash(userId)); -} - -export function isInClusterAdmin(user: User): boolean { - return normalizeUsername(user.username) === 'admin' && isInClusterMode(); -} - -export async function clearRequiresPasswordChange(userId: string): Promise { - const db = await getDb(); - await db.update(users).set({ requiresPasswordChange: false }).where(eq(users.id, userId)); -} - -// Internal helper: fetch the credential hash directly by userId, skipping the -// user-row re-fetch and in-cluster-admin re-check. Callers must have already -// handled the in-cluster-admin case before calling this. -async function getCredentialHashDirect(userId: string): Promise { - const db = await getDb(); - const account = await db.query.accounts.findFirst({ - where: and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')) - }); - return account?.password || null; -} - -export async function verifyManagedUserPassword(user: User, password: string): Promise { - if (normalizeUsername(user.username) === 'admin' && isInClusterMode()) { - return validateInClusterAdmin(password); - } - - const credentialPasswordHash = await getCredentialHashDirect(user.id); - if (!credentialPasswordHash) { - // Run dummy verification to prevent timing-based enumeration of SSO vs credential accounts - await verifyPassword(password, await DUMMY_HASH); - return false; - } - - return verifyPassword(password, credentialPasswordHash); -} - -// User management -export async function createUser( - username: string, - password: string, - role: 'admin' | 'editor' | 'viewer' = 'viewer', - email?: string -): Promise { - const db = await getDb(); - const passwordHash = await hashPassword(password); - const normalizedUsername = normalizeUsername(username); - - const newUser: NewUser = { - id: generateUserId(), - username: normalizedUsername, - name: normalizedUsername, - role, - email: email || null, - active: true - }; - - await db.transaction((tx) => { - tx.insert(users).values(newUser).run(); - tx.insert(accounts) - .values({ - id: generateUserId(), - providerId: 'credential', - accountId: newUser.id, - userId: newUser.id, - password: passwordHash - }) - .run(); - }); - const user = await db.query.users.findFirst({ - where: eq(users.id, newUser.id) - }); - - if (!user) { - throw new Error('Failed to create user'); - } - - // Auto-bind user to default RBAC policies based on role - await bindUserToDefaultPolicies(user); - - return user; -} - -export async function getUserById(id: string): Promise { - const db = await getDb(); - const user = await db.query.users.findFirst({ - where: eq(users.id, id) - }); - return user || null; -} - -export async function getUserByUsername(username: string): Promise { - const db = await getDb(); - const normalizedUsername = normalizeUsername(username); - const user = await db.query.users.findFirst({ - where: eq(users.username, normalizedUsername) - }); - return user || null; -} - -export async function updateUser( - id: string, - updates: Partial> -): Promise { - const db = await getDb(); - - // Get current user to check if role is changing - const currentUser = await getUserById(id); - const roleChanged = updates.role && currentUser && updates.role !== currentUser.role; - - await db - .update(users) - .set({ ...updates, updatedAt: new Date() }) - .where(eq(users.id, id)); - - const updatedUser = await getUserById(id); - - // If role changed, sync RBAC policy bindings - if (roleChanged && updatedUser) { - const { syncUserPolicyBindings } = await import('./rbac-defaults.js'); - await syncUserPolicyBindings(updatedUser); - } - - return updatedUser; -} - -export async function addPasswordHistory(userId: string, oldPasswordHash: string): Promise { - const db = await getDb(); - const now = Date.now(); - - // Insert and prune atomically so a crash between the two operations cannot - // leave the history table with more than PASSWORD_HISTORY_LIMIT entries. - // Ordering by createdAtMs (milliseconds) avoids ties at second resolution. - // The unique index on (user_id, password_hash) makes this idempotent across - // retries and concurrent rotations of the same live credential. - await db.transaction((tx) => { - tx.insert(passwordHistory) - .values({ - id: generateUserId(), - userId, - passwordHash: oldPasswordHash, - createdAtMs: now - }) - .onConflictDoNothing() - .run(); - - prunePasswordHistoryInTx(tx, userId); - }); -} - -export async function isPasswordInHistory( - userId: string, - candidatePassword: string -): Promise { - const db = await getDb(); - const rows = await db.query.passwordHistory.findMany({ - where: eq(passwordHistory.userId, userId), - orderBy: [desc(passwordHistory.createdAtMs)], - limit: PASSWORD_HISTORY_LIMIT - }); - - for (const row of rows) { - if (await bcrypt.compare(candidatePassword, row.passwordHash)) { - return true; - } - } - return false; -} - -export async function updateUserPassword(id: string, newPassword: string): Promise { - const db = await getDb(); - - // Hash before the transaction — bcrypt is async and cannot run inside a sync tx callback. - const newPasswordHash = await hashPassword(newPassword); - const now = Date.now(); - - await db.transaction((tx) => { - const currentCredential = tx - .select({ password: accounts.password }) - .from(accounts) - .where(and(eq(accounts.userId, id), eq(accounts.providerId, 'credential'))) - .get(); - const currentCredentialHash = currentCredential?.password || null; - - // Archive the old hash and prune history atomically with the password update - // so a crash between the two operations cannot leave the DB in a partial state. - if (currentCredentialHash) { - tx.insert(passwordHistory) - .values({ - id: generateUserId(), - userId: id, - passwordHash: currentCredentialHash, - createdAtMs: now - }) - .onConflictDoNothing() - .run(); - - prunePasswordHistoryInTx(tx, id); - } - - const updatedCredentialAccount = tx - .update(accounts) - .set({ password: newPasswordHash, updatedAt: new Date() }) - .where(and(eq(accounts.userId, id), eq(accounts.providerId, 'credential'))) - .run(); - - if (updatedCredentialAccount.changes === 0) { - tx.insert(accounts) - .values({ - id: generateUserId(), - providerId: 'credential', - accountId: id, - userId: id, - password: newPasswordHash - }) - .run(); - } - }); -} - -export async function deleteUser(id: string): Promise { - const db = await getDb(); - await db.delete(users).where(eq(users.id, id)); -} - -export async function listUsers(): Promise { - const db = await getDb(); - return db.query.users.findMany({ - orderBy: (users, { desc }) => [desc(users.createdAt)] - }); -} - -export async function listUsersPaginated(options?: { - search?: string; - limit?: number; - offset?: number; -}): Promise<{ users: User[]; total: number }> { - const result = await getPaginatedItems( - users, - (db) => db.query.users, - options, - (search) => { - const sanitized = sanitizeSearchInput(search); - const pattern = `%${sanitized}%`; - return or( - sql`${users.username} LIKE ${pattern} ESCAPE '\\'`, - sql`${users.email} LIKE ${pattern} ESCAPE '\\'` - ); - } - ); - - return { users: result.items, total: result.total }; -} - -export async function deleteUserSessions(userId: string): Promise { - const db = await getDb(); - await db.delete(sessions).where(eq(sessions.userId, userId)); -} - -export async function cleanupExpiredSessions(): Promise { - const db = await getDb(); - const now = new Date(); - const result = await db - .delete(sessions) - .where(lte(sessions.expiresAt, now)) - .returning({ id: sessions.id }); - - const count = result.length; - if (count > 0) { - sessionsCleanedUpTotal.inc(count); - logger.info(`[Auth] Cleaned up ${count} expired session(s)`); - } - return count; -} - -// Check if any users exist (for initial setup) -export async function hasUsers(): Promise { - try { - const db = getDbSync(); - const result = db - .select({ count: sql`count(*)` }) - .from(users) - .get(); - return (result?.count ?? 0) > 0; - } catch { - // Table doesn't exist yet - no users - return false; - } -} - -// Get current namespace from in-cluster ServiceAccount -function getCurrentNamespace(): string { - try { - return readFileSync(IN_CLUSTER_NAMESPACE_PATH, 'utf-8').trim(); - } catch { - return 'default'; - } -} - -// Create Kubernetes Core API client (works both in-cluster and locally) -async function createK8sClient(): Promise { - const { loadKubeConfig } = await import('./kubernetes/config.js'); - const kc = loadKubeConfig(); - return kc.makeApiClient(k8s.CoreV1Api); -} - -/** - * Check if the initial admin secret has been marked as consumed - */ -async function isSecretConsumed(api: k8s.CoreV1Api, namespace: string): Promise { - try { - const result = await api.readNamespacedSecret({ - name: ADMIN_SECRET_NAME, - namespace - }); - const labels = result.metadata?.labels || {}; - return labels['gyre.io/initial-password-consumed'] === 'true'; - } catch { - // If secret doesn't exist, it's not consumed - return false; - } -} - -/** - * Mark the initial admin secret as consumed after first login - */ -async function markSecretConsumed(api: k8s.CoreV1Api, namespace: string): Promise { - try { - // Patch the secret to add the consumed label using JSON Patch format - const patch = [ - { - op: 'add', - path: '/metadata/labels/gyre.io~1initial-password-consumed', - value: 'true' - } - ]; - await api.patchNamespacedSecret({ - name: ADMIN_SECRET_NAME, - namespace, - body: patch - }); - } catch (error) { - logger.error(error, 'Failed to mark secret as consumed:'); - } -} - -/** - * Load or create in-cluster admin password from Kubernetes secret - * - If secret exists and has password: load it - * - If secret doesn't exist: generate password and create secret - * - Returns the plaintext password (for initial display only) - */ -export async function loadOrCreateInClusterAdmin(): Promise { - try { - const api = await createK8sClient(); - const namespace = getCurrentNamespace(); - - // Check if secret already exists - try { - const result = await api.readNamespacedSecret({ - name: ADMIN_SECRET_NAME, - namespace - }); - const passwordBase64 = result.data?.['password']; - - if (passwordBase64) { - const password = Buffer.from(passwordBase64, 'base64').toString('utf-8'); - // Hash and store for authentication - inClusterAdminPasswordHash = await hashPassword(password); - - // Check if already consumed - inClusterFirstLoginDone = await isSecretConsumed(api, namespace); - - return password; - } - } catch (error: unknown) { - // Secret doesn't exist, will create it below - const k8sError = error as { response?: { statusCode: number } }; - if (k8sError.response?.statusCode !== 404) { - throw error; - } - } - - // Generate new password - // Use ADMIN_PASSWORD from env if provided, otherwise generate a strong one - const password = process.env.ADMIN_PASSWORD || generateStrongPassword(); - if (process.env.ADMIN_PASSWORD) warnIfWeakAdminPassword(process.env.ADMIN_PASSWORD); - pendingSetupCleanup = true; - - // Hash for storage - inClusterAdminPasswordHash = await hashPassword(password); - - // Create the secret - const secret: k8s.V1Secret = { - apiVersion: 'v1', - kind: 'Secret', - metadata: { - name: ADMIN_SECRET_NAME, - namespace, - labels: { - 'app.kubernetes.io/managed-by': 'gyre', - 'gyre.io/secret-type': 'initial-admin-password' - } - }, - stringData: { - password: password - } - }; - - await api.createNamespacedSecret({ - namespace, - body: secret - }); - logger.info(`Created initial admin secret in namespace "${namespace}"`); - - return password; - } catch (error) { - logger.error(error, 'Failed to setup in-cluster admin:'); - return null; - } -} - -/** - * Validate admin login for in-cluster mode - * - Checks against the K8s secret password - * - After first successful login, marks secret as consumed - */ -export async function validateInClusterAdmin(password: string): Promise { - if (!inClusterAdminPasswordHash) { - // Try to load from secret if not already loaded - await loadOrCreateInClusterAdmin(); - } - - if (!inClusterAdminPasswordHash) { - return false; - } - - const isValid = await bcrypt.compare(password, inClusterAdminPasswordHash); - - if (isValid && !inClusterFirstLoginDone) { - // Mark as consumed - try { - const api = await createK8sClient(); - const namespace = getCurrentNamespace(); - await markSecretConsumed(api, namespace); - inClusterFirstLoginDone = true; - } catch (error) { - logger.error(error, 'Failed to mark secret as consumed:'); - } - } - - return isValid; -} - -/** - * Check if in-cluster admin password has been used - */ -export function isInClusterAdminPasswordConsumed(): boolean { - return inClusterFirstLoginDone; -} - -/** - * Create default admin if no users exist - * - In-cluster mode: Uses K8s secret for password - * - Local development: Uses ADMIN_PASSWORD env var or generates a password - */ -export async function createDefaultAdminIfNeeded(): Promise<{ - password: string | null; - mode: string; -}> { - const hasAnyUsers = await hasUsers(); - - if (hasAnyUsers) { - return { password: null, mode: isInClusterMode() ? 'in-cluster' : 'local' }; - } - - // In-cluster mode: Use K8s secret - if (isInClusterMode()) { - const password = await loadOrCreateInClusterAdmin(); - if (password) { - const db = getDbSync(); - const newUser: NewUser = { - id: generateUserId(), - username: 'admin', - name: 'admin', - role: 'admin', - email: 'admin@gyre.local', - active: true, - requiresPasswordChange: false - }; - db.transaction((tx) => { - tx.insert(users).values(newUser).run(); - // Keep a credential account row for symmetry with local mode while - // leaving the Kubernetes secret as the sole password source of truth. - tx.insert(accounts) - .values({ - id: generateUserId(), - providerId: 'credential', - accountId: newUser.id, - userId: newUser.id, - password: null - }) - .run(); - }); - return { password, mode: 'in-cluster' }; - } - return { password: null, mode: 'in-cluster' }; - } - - // Local development mode: Use env var or generate password - const password = process.env.ADMIN_PASSWORD || generateStrongPassword(); - if (process.env.ADMIN_PASSWORD) warnIfWeakAdminPassword(process.env.ADMIN_PASSWORD); - pendingSetupCleanup = true; - - const db = getDbSync(); - const passwordHash = await hashPassword(password); - const newUser: NewUser = { - id: generateUserId(), - username: 'admin', - name: 'admin', - role: 'admin', - email: 'admin@gyre.local', - active: true, - requiresPasswordChange: true - }; - db.transaction((tx) => { - tx.insert(users).values(newUser).run(); - tx.insert(accounts) - .values({ - id: generateUserId(), - providerId: 'credential', - accountId: newUser.id, - userId: newUser.id, - password: passwordHash - }) - .run(); - }); - - return { password, mode: 'local' }; -} - -/** - * Check if running in-cluster (vs local development) - */ -function isInClusterMode(): boolean { - return !!process.env.KUBERNETES_SERVICE_HOST; -} - -/** - * Authenticate user - * - In-cluster mode: Admin validates against K8s secret, others against SQLite - * - Local dev mode: All users validate against SQLite - */ -export async function authenticateUser(username: string, password: string): Promise { - const user = await getUserByUsername(username); - - if (!user || !user.active) { - loginAttemptsTotal.labels('failure').inc(); - return null; - } - - const isValid = await verifyManagedUserPassword(user, password); - - if (!isValid) { - loginAttemptsTotal.labels('failure').inc(); - return null; - } - - loginAttemptsTotal.labels('success').inc(); - - // First successful login: delete the setup token file so credentials do not - // persist on disk beyond first use. - cleanupSetupTokenFile(); - - return user; -} - -import type { UserPreferences } from '$lib/types/user'; - -/** - * Shape user object for public consumption (e.g., in layout data) - */ -export function serializeUser(user: User | null): { - id: string; - username: string; - role: string; - email: string | null; - isLocal: boolean; - preferences: UserPreferences | null; -} | null { - if (!user) return null; - return { - id: user.id, - username: user.username, - role: user.role, - email: user.email, - isLocal: user.isLocal, - preferences: user.preferences || null - }; -} +export * from './auth/constants.js'; +export * from './auth/passwords.js'; +export * from './auth/credentials.js'; +export * from './auth/users.js'; +export * from './auth/sessions.js'; +export * from './auth/bootstrap-admin.js'; +export * from './auth/in-cluster-admin.js'; diff --git a/src/lib/server/auth/bootstrap-admin.ts b/src/lib/server/auth/bootstrap-admin.ts new file mode 100644 index 00000000..897953f6 --- /dev/null +++ b/src/lib/server/auth/bootstrap-admin.ts @@ -0,0 +1,173 @@ +import { logger } from '../logger.js'; +import { getDbSync, type NewUser, type User } from '../db/index.js'; +import { accounts, users } from '../db/schema.js'; +import { sql } from 'drizzle-orm'; +import { unlinkSync } from 'node:fs'; +import { loginAttemptsTotal } from '../metrics.js'; +import { + generateStrongPassword, + generateUserId, + hashPassword, + warnIfWeakAdminPassword +} from './passwords.js'; +import { getUserByUsername } from './users.js'; +import { verifyManagedUserPassword } from './credentials.js'; +import { isInClusterMode, loadOrCreateInClusterAdmin } from './in-cluster-admin.js'; + +// Tracks whether a setup token file has been written and not yet consumed +let pendingSetupCleanup = false; + +// Path to the local-dev setup token file; cleared after first successful login +let setupTokenFilePath: string | null = null; + +export function setSetupTokenFile(filePath: string): void { + setupTokenFilePath = filePath; +} + +export function cleanupSetupTokenFile(): void { + if (!pendingSetupCleanup || setupTokenFilePath === null) return; + const filePath = setupTokenFilePath; + try { + unlinkSync(filePath); + setupTokenFilePath = null; + pendingSetupCleanup = false; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + setupTokenFilePath = null; + pendingSetupCleanup = false; + } else { + logger.error( + { err, filePath }, + '[Auth] Failed to remove setup token file; manual removal may be required' + ); + } + } +} + +// Check if any users exist (for initial setup) +export async function hasUsers(): Promise { + try { + const db = getDbSync(); + const result = db + .select({ count: sql`count(*)` }) + .from(users) + .get(); + return (result?.count ?? 0) > 0; + } catch { + // Table doesn't exist yet - no users + return false; + } +} + +/** + * Create default admin if no users exist + * - In-cluster mode: Uses K8s secret for password + * - Local development: Uses ADMIN_PASSWORD env var or generates a password + */ +export async function createDefaultAdminIfNeeded(options?: { + persistSetupToken?: (password: string) => void; +}): Promise<{ + password: string | null; + mode: string; +}> { + const hasAnyUsers = await hasUsers(); + + if (hasAnyUsers) { + return { password: null, mode: isInClusterMode() ? 'in-cluster' : 'local' }; + } + + // In-cluster mode: Use K8s secret + if (isInClusterMode()) { + const password = await loadOrCreateInClusterAdmin(); + if (password) { + const db = getDbSync(); + const newUser: NewUser = { + id: generateUserId(), + username: 'admin', + name: 'admin', + role: 'admin', + email: 'admin@gyre.local', + active: true, + requiresPasswordChange: false + }; + db.transaction((tx) => { + tx.insert(users).values(newUser).run(); + // Keep a credential account row for symmetry with local mode while + // leaving the Kubernetes secret as the sole password source of truth. + tx.insert(accounts) + .values({ + id: generateUserId(), + providerId: 'credential', + accountId: newUser.id, + userId: newUser.id, + password: null + }) + .run(); + }); + return { password, mode: 'in-cluster' }; + } + return { password: null, mode: 'in-cluster' }; + } + + // Local development mode: Use env var or generate password + const password = process.env.ADMIN_PASSWORD || generateStrongPassword(); + if (process.env.ADMIN_PASSWORD) warnIfWeakAdminPassword(process.env.ADMIN_PASSWORD); + + options?.persistSetupToken?.(password); + pendingSetupCleanup = true; + + const db = getDbSync(); + const passwordHash = await hashPassword(password); + const newUser: NewUser = { + id: generateUserId(), + username: 'admin', + name: 'admin', + role: 'admin', + email: 'admin@gyre.local', + active: true, + requiresPasswordChange: true + }; + db.transaction((tx) => { + tx.insert(users).values(newUser).run(); + tx.insert(accounts) + .values({ + id: generateUserId(), + providerId: 'credential', + accountId: newUser.id, + userId: newUser.id, + password: passwordHash + }) + .run(); + }); + + return { password, mode: 'local' }; +} + +/** + * Authenticate user + * - In-cluster mode: Admin validates against K8s secret, others against SQLite + * - Local dev mode: All users validate against SQLite + */ +export async function authenticateUser(username: string, password: string): Promise { + const user = await getUserByUsername(username); + + if (!user || !user.active) { + loginAttemptsTotal.labels('failure').inc(); + return null; + } + + const isValid = await verifyManagedUserPassword(user, password); + + if (!isValid) { + loginAttemptsTotal.labels('failure').inc(); + return null; + } + + loginAttemptsTotal.labels('success').inc(); + + // First successful login: delete the setup token file so credentials do not + // persist on disk beyond first use. + cleanupSetupTokenFile(); + + return user; +} diff --git a/src/lib/server/auth/constants.ts b/src/lib/server/auth/constants.ts new file mode 100644 index 00000000..a9319872 --- /dev/null +++ b/src/lib/server/auth/constants.ts @@ -0,0 +1,7 @@ +const _maxSessionsEnv = parseInt(process.env.MAX_SESSIONS_PER_USER ?? '', 10); + +export const SESSION_DURATION_DAYS = 2; +export const MAX_SESSIONS_PER_USER = + Number.isFinite(_maxSessionsEnv) && _maxSessionsEnv > 0 ? _maxSessionsEnv : 10; +export const PASSWORD_HISTORY_LIMIT = 5; +export const SALT_ROUNDS = 12; diff --git a/src/lib/server/auth/credentials.ts b/src/lib/server/auth/credentials.ts new file mode 100644 index 00000000..eb67c514 --- /dev/null +++ b/src/lib/server/auth/credentials.ts @@ -0,0 +1,191 @@ +import bcrypt from 'bcryptjs'; +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { getDb, type Account, type User } from '../db/index.js'; +import { accounts, passwordHistory, users } from '../db/schema.js'; +import { PASSWORD_HISTORY_LIMIT, SALT_ROUNDS } from './constants.js'; +import { generateUserId, hashPassword, normalizeUsername, verifyPassword } from './passwords.js'; +import { isInClusterMode, validateInClusterAdmin } from './in-cluster-admin.js'; + +type Tx = Parameters>['transaction']>[0]>[0]; + +function prunePasswordHistoryInTx(tx: Tx, userId: string): void { + const rows = tx + .select({ id: passwordHistory.id }) + .from(passwordHistory) + .where(eq(passwordHistory.userId, userId)) + .orderBy(desc(passwordHistory.createdAtMs)) + .all(); + + if (rows.length > PASSWORD_HISTORY_LIMIT) { + const toDelete = rows.slice(PASSWORD_HISTORY_LIMIT).map((r) => r.id); + tx.delete(passwordHistory).where(inArray(passwordHistory.id, toDelete)).run(); + } +} + +// Pre-computed dummy hash for constant-time comparisons (prevents timing-based enumeration) +const DUMMY_HASH: Promise = bcrypt.hash('__dummy_password_for_timing__', SALT_ROUNDS); + +export async function getCredentialPasswordHash(userId: string): Promise { + const db = await getDb(); + const user = await db.query.users.findFirst({ + where: eq(users.id, userId) + }); + + if (!user) { + return null; + } + + if (normalizeUsername(user.username) === 'admin' && isInClusterMode()) { + return null; + } + + const account = await db.query.accounts.findFirst({ + where: and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')) + }); + + return account?.password || null; +} + +export async function getCredentialAccount(userId: string): Promise { + const db = await getDb(); + const account = await db.query.accounts.findFirst({ + where: and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')) + }); + + return account || null; +} + +export async function hasManagedPassword(userId: string): Promise { + return Boolean(await getCredentialPasswordHash(userId)); +} + +export async function clearRequiresPasswordChange(userId: string): Promise { + const db = await getDb(); + await db.update(users).set({ requiresPasswordChange: false }).where(eq(users.id, userId)); +} + +// Internal helper: fetch the credential hash directly by userId, skipping the +// user-row re-fetch and in-cluster-admin re-check. Callers must have already +// handled the in-cluster-admin case before calling this. +async function getCredentialHashDirect(userId: string): Promise { + const db = await getDb(); + const account = await db.query.accounts.findFirst({ + where: and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')) + }); + return account?.password || null; +} + +export async function verifyManagedUserPassword(user: User, password: string): Promise { + if (normalizeUsername(user.username) === 'admin' && isInClusterMode()) { + return validateInClusterAdmin(password); + } + + const credentialPasswordHash = await getCredentialHashDirect(user.id); + if (!credentialPasswordHash) { + // Run dummy verification to prevent timing-based enumeration of SSO vs credential accounts + await verifyPassword(password, await DUMMY_HASH); + return false; + } + + return verifyPassword(password, credentialPasswordHash); +} + +export async function addPasswordHistory(userId: string, oldPasswordHash: string): Promise { + const db = await getDb(); + const now = Date.now(); + + // Insert and prune atomically so a crash between the two operations cannot + // leave the history table with more than PASSWORD_HISTORY_LIMIT entries. + // Ordering by createdAtMs (milliseconds) avoids ties at second resolution. + // The unique index on (user_id, password_hash) makes this idempotent across + // retries and concurrent rotations of the same live credential. + await db.transaction((tx) => { + tx.insert(passwordHistory) + .values({ + id: generateUserId(), + userId, + passwordHash: oldPasswordHash, + createdAtMs: now + }) + .onConflictDoNothing() + .run(); + + prunePasswordHistoryInTx(tx, userId); + }); +} + +export async function isPasswordInHistory( + userId: string, + candidatePassword: string +): Promise { + const db = await getDb(); + const rows = await db.query.passwordHistory.findMany({ + where: eq(passwordHistory.userId, userId), + orderBy: [desc(passwordHistory.createdAtMs)], + limit: PASSWORD_HISTORY_LIMIT + }); + + for (const row of rows) { + if (await bcrypt.compare(candidatePassword, row.passwordHash)) { + return true; + } + } + return false; +} + +export async function updateUserPassword(id: string, newPassword: string): Promise { + const db = await getDb(); + + // Hash before the transaction — bcrypt is async and cannot run inside a sync tx callback. + const newPasswordHash = await hashPassword(newPassword); + const now = Date.now(); + + await db.transaction((tx) => { + const existingUser = tx.select({ id: users.id }).from(users).where(eq(users.id, id)).get(); + + if (!existingUser) { + throw new Error('User not found'); + } + + const currentCredential = tx + .select({ password: accounts.password }) + .from(accounts) + .where(and(eq(accounts.userId, id), eq(accounts.providerId, 'credential'))) + .get(); + const currentCredentialHash = currentCredential?.password || null; + + // Archive the old hash and prune history atomically with the password update + // so a crash between the two operations cannot leave the DB in a partial state. + if (currentCredentialHash) { + tx.insert(passwordHistory) + .values({ + id: generateUserId(), + userId: id, + passwordHash: currentCredentialHash, + createdAtMs: now + }) + .onConflictDoNothing() + .run(); + + prunePasswordHistoryInTx(tx, id); + } + + const updatedCredentialAccount = tx + .update(accounts) + .set({ password: newPasswordHash, updatedAt: new Date() }) + .where(and(eq(accounts.userId, id), eq(accounts.providerId, 'credential'))) + .run(); + + if (updatedCredentialAccount.changes === 0) { + tx.insert(accounts) + .values({ + id: generateUserId(), + providerId: 'credential', + accountId: id, + userId: id, + password: newPasswordHash + }) + .run(); + } + }); +} diff --git a/src/lib/server/auth/in-cluster-admin.ts b/src/lib/server/auth/in-cluster-admin.ts new file mode 100644 index 00000000..5f93a643 --- /dev/null +++ b/src/lib/server/auth/in-cluster-admin.ts @@ -0,0 +1,181 @@ +import { logger } from '../logger.js'; +import * as k8s from '@kubernetes/client-node'; +import type { User } from '../db/index.js'; +import { + generateStrongPassword, + hashPassword, + normalizeUsername, + verifyPassword, + warnIfWeakAdminPassword +} from './passwords.js'; +import { getCurrentNamespace } from '../kubernetes/namespace.js'; + +const ADMIN_SECRET_NAME = 'gyre-initial-admin-secret'; + +let inClusterAdminPasswordHash: string | null = null; +let inClusterFirstLoginDone = false; + +export function isInClusterMode(): boolean { + return !!process.env.KUBERNETES_SERVICE_HOST; +} + +export function isInClusterAdmin(user: User): boolean { + return normalizeUsername(user.username) === 'admin' && isInClusterMode(); +} + +// Create Kubernetes Core API client (works both in-cluster and locally) +async function createK8sClient(): Promise { + const { loadKubeConfig } = await import('../kubernetes/config.js'); + const kc = loadKubeConfig(); + return kc.makeApiClient(k8s.CoreV1Api); +} + +/** + * Check if the initial admin secret has been marked as consumed + */ +async function isSecretConsumed(api: k8s.CoreV1Api, namespace: string): Promise { + try { + const result = await api.readNamespacedSecret({ + name: ADMIN_SECRET_NAME, + namespace + }); + const labels = result.metadata?.labels || {}; + return labels['gyre.io/initial-password-consumed'] === 'true'; + } catch { + // If secret doesn't exist, it's not consumed + return false; + } +} + +/** + * Mark the initial admin secret as consumed after first login + */ +async function markSecretConsumed(api: k8s.CoreV1Api, namespace: string): Promise { + // Patch the secret to add the consumed label using JSON Patch format + const patch = [ + { + op: 'add', + path: '/metadata/labels/gyre.io~1initial-password-consumed', + value: 'true' + } + ]; + await api.patchNamespacedSecret({ + name: ADMIN_SECRET_NAME, + namespace, + body: patch + }); +} + +/** + * Load or create in-cluster admin password from Kubernetes secret + * - If secret exists and has password: load it + * - If secret doesn't exist: generate password and create secret + * - Returns the plaintext password (for initial display only) + */ +export async function loadOrCreateInClusterAdmin(): Promise { + try { + const api = await createK8sClient(); + const namespace = getCurrentNamespace(); + + // Check if secret already exists + try { + const result = await api.readNamespacedSecret({ + name: ADMIN_SECRET_NAME, + namespace + }); + const passwordBase64 = result.data?.['password']; + + if (passwordBase64) { + const password = Buffer.from(passwordBase64, 'base64').toString('utf-8'); + // Hash and store for authentication + inClusterAdminPasswordHash = await hashPassword(password); + + // Check if already consumed + inClusterFirstLoginDone = await isSecretConsumed(api, namespace); + + return password; + } + } catch (error: unknown) { + // Secret doesn't exist, will create it below + const k8sError = error as Error & { code?: number }; + if (k8sError.code !== 404) { + throw error; + } + } + + // Generate new password + // Use ADMIN_PASSWORD from env if provided, otherwise generate a strong one + const password = process.env.ADMIN_PASSWORD || generateStrongPassword(); + if (process.env.ADMIN_PASSWORD) warnIfWeakAdminPassword(process.env.ADMIN_PASSWORD); + + // Hash for storage + inClusterAdminPasswordHash = await hashPassword(password); + + // Create the secret + const secret: k8s.V1Secret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: ADMIN_SECRET_NAME, + namespace, + labels: { + 'app.kubernetes.io/managed-by': 'gyre', + 'gyre.io/secret-type': 'initial-admin-password' + } + }, + stringData: { + password: password + } + }; + + await api.createNamespacedSecret({ + namespace, + body: secret + }); + logger.info(`Created initial admin secret in namespace "${namespace}"`); + + return password; + } catch (error) { + logger.error(error, 'Failed to setup in-cluster admin:'); + throw error; + } +} + +/** + * Validate admin login for in-cluster mode + * - Checks against the K8s secret password + * - After first successful login, marks secret as consumed + */ +export async function validateInClusterAdmin(password: string): Promise { + if (!inClusterAdminPasswordHash) { + // Try to load from secret if not already loaded + await loadOrCreateInClusterAdmin(); + } + + if (!inClusterAdminPasswordHash) { + return false; + } + + const isValid = await verifyPassword(password, inClusterAdminPasswordHash); + + if (isValid && !inClusterFirstLoginDone) { + // Mark as consumed + try { + const api = await createK8sClient(); + const namespace = getCurrentNamespace(); + await markSecretConsumed(api, namespace); + inClusterFirstLoginDone = true; + } catch (error) { + logger.error(error, 'Failed to mark secret as consumed:'); + } + } + + return isValid; +} + +/** + * Check if in-cluster admin password has been used + */ +export function isInClusterAdminPasswordConsumed(): boolean { + return inClusterFirstLoginDone; +} diff --git a/src/lib/server/auth/passwords.ts b/src/lib/server/auth/passwords.ts new file mode 100644 index 00000000..c29afb85 --- /dev/null +++ b/src/lib/server/auth/passwords.ts @@ -0,0 +1,72 @@ +import bcrypt from 'bcryptjs'; +import { randomBytes, randomInt } from 'node:crypto'; +import { logger } from '../logger.js'; +import { passwordSchema } from '$lib/utils/validation.js'; +import { SALT_ROUNDS } from './constants.js'; + +export function warnIfWeakAdminPassword(password: string): void { + const result = passwordSchema.safeParse(password); + if (!result.success) { + logger.warn( + { issues: result.error.issues.map((i) => i.message) }, + '⚠️ ADMIN_PASSWORD does not meet strength requirements.' + ); + } +} + +/** + * Normalize username to a canonical form (lowercase, trimmed) + */ +export function normalizeUsername(username: string): string { + return username.toLowerCase().trim(); +} + +/** + * Generate a strong random password +... + * Format: 32 CSPRNG-selected chars with at least one char from each class. + */ +export function generateStrongPassword(): string { + const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lower = 'abcdefghijklmnopqrstuvwxyz'; + const digits = '0123456789'; + const specials = '!@#$%^&*'; + const alphabet = `${upper}${lower}${digits}${specials}`; + + const pick = (chars: string) => chars[randomInt(0, chars.length)]; + const passwordChars = [ + pick(upper), + pick(lower), + pick(digits), + pick(specials), + ...Array.from({ length: 28 }, () => pick(alphabet)) + ]; + + for (let i = passwordChars.length - 1; i > 0; i--) { + const j = randomInt(0, i + 1); + [passwordChars[i], passwordChars[j]] = [passwordChars[j], passwordChars[i]]; + } + + return passwordChars.join(''); +} + +/** + * @deprecated The generated admin password is no longer stored in memory. + * It is returned directly from createDefaultAdminIfNeeded() and written to a restricted temp file. + */ +export function getGeneratedAdminPassword(): string | null { + return null; +} + +// Password hashing +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} + +export function generateUserId(): string { + return randomBytes(16).toString('hex'); +} diff --git a/src/lib/server/auth/sessions.ts b/src/lib/server/auth/sessions.ts new file mode 100644 index 00000000..e1bf8c64 --- /dev/null +++ b/src/lib/server/auth/sessions.ts @@ -0,0 +1,32 @@ +import { randomBytes } from 'node:crypto'; +import { lte, eq } from 'drizzle-orm'; +import { logger } from '../logger.js'; +import { getDb } from '../db/index.js'; +import { sessions } from '../db/schema.js'; +import { sessionsCleanedUpTotal } from '../metrics.js'; + +// Session ID generation +export function generateSessionId(): string { + return randomBytes(32).toString('hex'); +} + +export async function deleteUserSessions(userId: string): Promise { + const db = await getDb(); + await db.delete(sessions).where(eq(sessions.userId, userId)); +} + +export async function cleanupExpiredSessions(): Promise { + const db = await getDb(); + const now = new Date(); + const result = await db + .delete(sessions) + .where(lte(sessions.expiresAt, now)) + .returning({ id: sessions.id }); + + const count = result.length; + if (count > 0) { + sessionsCleanedUpTotal.inc(count); + logger.info(`[Auth] Cleaned up ${count} expired session(s)`); + } + return count; +} diff --git a/src/lib/server/auth/users.ts b/src/lib/server/auth/users.ts new file mode 100644 index 00000000..d534319c --- /dev/null +++ b/src/lib/server/auth/users.ts @@ -0,0 +1,152 @@ +import { eq, or, sql } from 'drizzle-orm'; +import { getDb, type NewUser, type User } from '../db/index.js'; +import { accounts, users } from '../db/schema.js'; +import { getPaginatedItems, sanitizeSearchInput } from '../db/utils.js'; +import { bindUserToDefaultPoliciesInTx, syncUserPolicyBindingsInTx } from '../rbac-defaults.js'; +import { generateUserId, hashPassword, normalizeUsername } from './passwords.js'; +import type { UserPreferences } from '$lib/types/user'; + +// User management +export async function createUser( + username: string, + password: string, + role: 'admin' | 'editor' | 'viewer' = 'viewer', + email?: string +): Promise { + const db = await getDb(); + const passwordHash = await hashPassword(password); + const normalizedUsername = normalizeUsername(username); + + const newUser: NewUser = { + id: generateUserId(), + username: normalizedUsername, + name: normalizedUsername, + role, + email: email || null, + active: true + }; + + const user = await db.transaction((tx) => { + tx.insert(users).values(newUser).run(); + tx.insert(accounts) + .values({ + id: generateUserId(), + providerId: 'credential', + accountId: newUser.id, + userId: newUser.id, + password: passwordHash + }) + .run(); + + const createdUser = tx.select().from(users).where(eq(users.id, newUser.id)).get(); + + if (!createdUser) { + throw new Error('Failed to create user'); + } + + // Auto-bind user to default RBAC policies based on role + bindUserToDefaultPoliciesInTx(tx, createdUser); + return createdUser; + }); + + return user; +} + +export async function getUserById(id: string): Promise { + const db = await getDb(); + const user = await db.query.users.findFirst({ + where: eq(users.id, id) + }); + return user || null; +} + +export async function getUserByUsername(username: string): Promise { + const db = await getDb(); + const normalizedUsername = normalizeUsername(username); + const user = await db.query.users.findFirst({ + where: eq(users.username, normalizedUsername) + }); + return user || null; +} + +export async function updateUser( + id: string, + updates: Partial> +): Promise { + const db = await getDb(); + + return await db.transaction((tx) => { + const currentUser = tx.select().from(users).where(eq(users.id, id)).get(); + if (!currentUser) return null; + + const roleChanged = updates.role !== undefined && updates.role !== currentUser.role; + + tx.update(users) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(users.id, id)) + .run(); + + const updatedUser = tx.select().from(users).where(eq(users.id, id)).get(); + if (!updatedUser) return null; + + // If role changed, sync RBAC policy bindings in the same transaction + if (roleChanged) { + syncUserPolicyBindingsInTx(tx, updatedUser); + } + + return updatedUser; + }); +} + +export async function deleteUser(id: string): Promise { + const db = await getDb(); + await db.delete(users).where(eq(users.id, id)); +} + +export async function listUsers(): Promise { + const db = await getDb(); + return db.query.users.findMany({ + orderBy: (users, { desc }) => [desc(users.createdAt)] + }); +} + +export async function listUsersPaginated(options?: { + search?: string; + limit?: number; + offset?: number; +}): Promise<{ users: User[]; total: number }> { + const result = await getPaginatedItems( + users, + (db) => db.query.users, + options, + (search) => { + const sanitized = sanitizeSearchInput(search); + const pattern = `%${sanitized}%`; + return or( + sql`${users.username} LIKE ${pattern} ESCAPE '\\'`, + sql`${users.email} LIKE ${pattern} ESCAPE '\\'` + ); + } + ); + + return { users: result.items, total: result.total }; +} + +export function serializeUser(user: User | null): { + id: string; + username: string; + role: string; + email: string | null; + isLocal: boolean; + preferences: UserPreferences | null; +} | null { + if (!user) return null; + return { + id: user.id, + username: user.username, + role: user.role, + email: user.email, + isLocal: user.isLocal, + preferences: user.preferences || null + }; +} diff --git a/src/lib/server/clusters.ts b/src/lib/server/clusters.ts index 32d454c5..b699b7f1 100644 --- a/src/lib/server/clusters.ts +++ b/src/lib/server/clusters.ts @@ -1,667 +1,6 @@ -import { logger } from './logger.js'; -import { IN_CLUSTER_ID, type ClusterOption } from '$lib/clusters/identity.js'; -import { eq, desc, or, sql } from 'drizzle-orm'; -import { getDbSync } from './db/index.js'; -import { getPaginatedItems, sanitizeSearchInput } from './db/utils.js'; -import { clusters, clusterContexts, type NewCluster, type NewClusterContext } from './db/schema.js'; -import * as k8s from '@kubernetes/client-node'; -import crypto from 'node:crypto'; -import yaml from 'js-yaml'; -import { sanitizeK8sErrorMessage } from './kubernetes/errors.js'; - -/** - * Get the encryption key for kubeconfigs from environment. - * In production, this MUST be set via GYRE_ENCRYPTION_KEY env var. - * For development, a default key is used. - */ -function getEncryptionKey(): string { - const key = process.env.GYRE_ENCRYPTION_KEY; - const isProd = process.env.NODE_ENV === 'production'; - - if (!key) { - if (isProd) { - throw new Error( - 'GYRE_ENCRYPTION_KEY must be set in production! ' + - 'Please set it to a 64-character hexadecimal string.' - ); - } - const devKey = crypto.randomBytes(32).toString('hex'); - logger.warn( - '⚠️ GYRE_ENCRYPTION_KEY not set! Using ephemeral random key. Encrypted kubeconfigs will be unreadable after restart. Set GYRE_ENCRYPTION_KEY to persist.' - ); - return devKey; - } - - // Validate key format (should be 64 hex characters = 32 bytes) - if (!/^[0-9a-f]{64}$/i.test(key)) { - throw new Error( - 'GYRE_ENCRYPTION_KEY must be 64 hexadecimal characters (32 bytes). Generate with: openssl rand -hex 32' - ); - } - - return key; -} - -let _encryptionKey: string | null = null; - -function getEncryptionKeyLazy(): string { - if (!_encryptionKey) { - _encryptionKey = getEncryptionKey(); - } - return _encryptionKey; -} - -/** - * Check if the encryption key is the insecure development default. - */ -export function isUsingDevelopmentKey(): boolean { - return !process.env.GYRE_ENCRYPTION_KEY; -} - -/** - * Validate that encryption/decryption works correctly. - */ -export function testEncryption(): boolean { - try { - const testSecret = 'test-kubeconfig-' + crypto.randomUUID(); - const encrypted = encryptKubeconfig(testSecret); - const decrypted = decryptKubeconfig(encrypted); - return testSecret === decrypted; - } catch { - return false; - } -} - -const ALGORITHM = 'aes-256-gcm'; - -/** - * Encrypt kubeconfig string using AES-256-GCM - * Format: v2:iv:ciphertext:authTag (all hex except v2 prefix) - */ -function encryptKubeconfig(kubeconfig: string): string { - const iv = crypto.randomBytes(16); - const key = Buffer.from(getEncryptionKeyLazy(), 'hex'); // We validated it's 32 bytes hex - - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - - let encrypted = cipher.update(kubeconfig, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - - const authTag = cipher.getAuthTag(); - - // Return format: v2:iv:ciphertext:authTag - return `v2:${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`; -} - -/** - * Decrypt kubeconfig encrypted with legacy XOR cipher. - * Used only by migrateKubeconfigs() to read pre-migration records. - */ -function decryptLegacyXorKubeconfig(encrypted: string): string { - const buffer = Buffer.from(encrypted, 'base64'); - const decrypted = Buffer.alloc(buffer.length); - const key = getEncryptionKeyLazy(); - for (let i = 0; i < buffer.length; i++) { - decrypted[i] = buffer[i] ^ key.charCodeAt(i % key.length); - } - return decrypted.toString('utf-8'); -} - -/** - * Decrypt kubeconfig string. - * Only AES-256-GCM (v2) format is supported. - */ -function decryptKubeconfig(encrypted: string): string { - // Check if it's the new v2 format - if (encrypted.startsWith('v2:')) { - const parts = encrypted.split(':'); - if (parts.length !== 4) { - throw new Error('Invalid v2 encrypted kubeconfig format'); - } - - const [, ivHex, ciphertext, authTagHex] = parts; - const key = Buffer.from(getEncryptionKeyLazy(), 'hex'); - const iv = Buffer.from(ivHex, 'hex'); - const authTag = Buffer.from(authTagHex, 'hex'); - - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; - } - - throw new Error( - 'Unsupported kubeconfig encryption format: only v2 (AES-256-GCM) is supported. ' + - 'Run migrateKubeconfigs() to upgrade any legacy records.' - ); -} - -/** - * Parse kubeconfig and extract contexts - */ -function parseKubeconfig(kubeconfig: string): { - contexts: string[]; - currentContext: string | null; -} { - try { - const kc = new k8s.KubeConfig(); - kc.loadFromString(kubeconfig); - - const contexts = kc.getContexts().map((ctx) => ctx.name); - const currentContext = kc.getCurrentContext(); - - return { contexts, currentContext }; - } catch { - logger.error('Failed to parse kubeconfig: parse error (sanitized)'); - return { contexts: [], currentContext: null }; - } -} - -/** - * Create a new cluster - */ -export async function createCluster(params: { - name: string; - description?: string; - kubeconfig: string; - isLocal: boolean; -}): Promise { - const db = getDbSync(); - - const id = crypto.randomUUID(); - const encryptedKubeconfig = encryptKubeconfig(params.kubeconfig); - const { contexts, currentContext } = parseKubeconfig(params.kubeconfig); - const uniqueContexts = [...new Set(contexts)]; - - // Create cluster - const newCluster: NewCluster = { - id, - name: params.name, - description: params.description || null, - kubeconfigEncrypted: encryptedKubeconfig, - isActive: true, - isLocal: params.isLocal, - contextCount: uniqueContexts.length, - lastConnectedAt: null, - lastError: null - }; - const contextRecords: NewClusterContext[] = uniqueContexts.map((ctxName) => ({ - id: crypto.randomUUID(), - clusterId: id, - contextName: ctxName, - isCurrent: ctxName === currentContext, - server: null, - namespaceRestrictions: null - })); - - db.transaction((tx) => { - tx.insert(clusters).values(newCluster).run(); - if (contextRecords.length > 0) { - tx.insert(clusterContexts).values(contextRecords).run(); - } - }); - - const cluster = await db.query.clusters.findFirst({ - where: eq(clusters.id, id) - }); - - if (!cluster) { - throw new Error('Failed to create cluster'); - } - - return cluster; -} - -/** - * Get all clusters - */ -export async function getAllClusters(): Promise<(typeof clusters.$inferSelect)[]> { - const db = getDbSync(); - return db.query.clusters.findMany({ - orderBy: [desc(clusters.createdAt)] - }); -} - -/** - * Get selectable cluster identities for UI/API selection. - * Kubeconfig context names are diagnostic metadata only; uploaded clusters are - * selected by their stable clusters.id values. - */ -export async function getSelectableClusters( - currentContext?: string | null -): Promise { - const uploadedClusters = (await getAllClusters()).filter((cluster) => cluster.isActive); - - return [ - { - id: IN_CLUSTER_ID, - name: 'In-cluster', - description: 'Runtime Kubernetes configuration', - source: 'in-cluster', - isActive: true, - currentContext - }, - ...uploadedClusters.map((cluster) => ({ - id: cluster.id, - name: cluster.name, - description: cluster.description, - source: 'uploaded' as const, - isActive: cluster.isActive, - currentContext: null - })) - ]; -} - -/** - * Get clusters with pagination and search - */ -export async function getAllClustersPaginated(options?: { - search?: string; - limit?: number; - offset?: number; -}): Promise<{ clusters: (typeof clusters.$inferSelect)[]; total: number }> { - const result = await getPaginatedItems( - clusters, - (db) => db.query.clusters, - options, - (search) => { - const sanitized = sanitizeSearchInput(search); - const pattern = `%${sanitized}%`; - return or( - sql`${clusters.name} LIKE ${pattern} ESCAPE '\\'`, - sql`${clusters.description} LIKE ${pattern} ESCAPE '\\'` - ); - } - ); - - return { clusters: result.items, total: result.total }; -} - -/** - * Get cluster by ID - */ -export async function getClusterById(id: string): Promise { - const db = getDbSync(); - const cluster = await db.query.clusters.findFirst({ - where: eq(clusters.id, id) - }); - return cluster || null; -} - -/** - * Update cluster - */ -export async function updateCluster( - id: string, - updates: Partial< - Pick - > -): Promise { - const db = getDbSync(); - - await db - .update(clusters) - .set({ ...updates, updatedAt: new Date() }) - .where(eq(clusters.id, id)); - - return getClusterById(id); -} - -/** - * Delete cluster - */ -export async function deleteCluster(id: string): Promise { - const db = getDbSync(); - - // Delete contexts first (cascade should handle this but being explicit) - await db.delete(clusterContexts).where(eq(clusterContexts.clusterId, id)); - - // Delete cluster - await db.delete(clusters).where(eq(clusters.id, id)); -} - -/** - * Get decrypted kubeconfig - */ -export async function getClusterKubeconfig(id: string): Promise { - const cluster = await getClusterById(id); - if (!cluster || !cluster.kubeconfigEncrypted) { - return null; - } - - return decryptKubeconfig(cluster.kubeconfigEncrypted); -} - -/** - * Health check result for a single diagnostic test - */ -export interface HealthCheckResult { - name: string; - passed: boolean; - message: string; - details?: string; - duration?: number; -} - -/** - * Detailed cluster connection test result - */ -export interface ClusterHealthCheck { - connected: boolean; - clusterName: string; - kubernetesVersion?: string; - checks: HealthCheckResult[]; - error?: string; - timestamp: Date; -} - -function checkKubeconfigParse(kubeconfig: string): { - check: HealthCheckResult; - kc?: k8s.KubeConfig; -} { - const kc = new k8s.KubeConfig(); - const start = Date.now(); - try { - kc.loadFromString(kubeconfig); - return { - check: { - name: 'Kubeconfig Parse', - passed: true, - message: 'Kubeconfig is valid YAML/JSON', - duration: Date.now() - start - }, - kc - }; - } catch (parseError) { - const error = parseError instanceof Error ? parseError.message : 'Invalid kubeconfig format'; - return { - check: { - name: 'Kubeconfig Parse', - passed: false, - message: 'Failed to parse kubeconfig', - details: sanitizeK8sErrorMessage(error), - duration: Date.now() - start - } - }; - } -} - -async function checkApiReachability(kc: k8s.KubeConfig): Promise { - const start = Date.now(); - try { - const coreApi = kc.makeApiClient(k8s.CoreV1Api); - await coreApi.getAPIResources(); - return { - name: 'API Server Reachability', - passed: true, - message: 'Successfully connected to Kubernetes API server', - duration: Date.now() - start - }; - } catch (networkError) { - const error = networkError instanceof Error ? networkError.message : 'Network error'; - - // Auth/cert/authz errors must bubble up to checkAuthAndVersion for proper diagnosis - if ( - error.includes('Unauthorized') || - error.includes('401') || - error.includes('Forbidden') || - error.includes('403') || - error.includes('certificate') || - error.includes('x509') - ) { - throw networkError; - } - - let details = error; - if (error.includes('ENOTFOUND') || error.includes('getaddrinfo')) { - details = 'DNS resolution failed. Check if the server address in kubeconfig is correct.'; - } else if (error.includes('ECONNREFUSED') || error.includes('ECONNRESET')) { - details = 'Connection refused. Check if the Kubernetes API server is running and accessible.'; - } else if (error.includes('ETIMEDOUT') || error.includes('timeout')) { - details = 'Connection timed out. Check network connectivity and firewall rules.'; - } - - return { - name: 'API Server Reachability', - passed: false, - message: 'Failed to reach Kubernetes API server', - details: sanitizeK8sErrorMessage(details), - duration: Date.now() - start - }; - } -} - -async function checkAuthAndVersion( - kc: k8s.KubeConfig -): Promise<{ checks: HealthCheckResult[]; version?: string; error?: string }> { - const authStart = Date.now(); - const checks: HealthCheckResult[] = []; - - try { - const coreApi = kc.makeApiClient(k8s.CoreV1Api); - await coreApi.listNamespace({ limit: 1 }); - - const currentUser = kc.getCurrentUser(); - const userInfo = currentUser ? `User: ${currentUser.name}` : 'ServiceAccount'; - - checks.push({ - name: 'Authentication', - passed: true, - message: 'Authentication successful', - details: userInfo, - duration: Date.now() - authStart - }); - checks.push({ - name: 'Authorization', - passed: true, - message: 'Successfully listed namespaces', - details: 'Namespace access confirmed', - duration: Date.now() - authStart - }); - - // Version check is optional - const versionStart = Date.now(); - try { - const versionApi = kc.makeApiClient(k8s.VersionApi); - const versionResponse = await versionApi.getCode(); - const version = versionResponse.gitVersion; - checks.push({ - name: 'Kubernetes Version', - passed: true, - message: `Cluster version detected: ${version}`, - duration: Date.now() - versionStart - }); - return { checks, version }; - } catch { - checks.push({ - name: 'Kubernetes Version', - passed: false, - message: 'Connected, but failed to retrieve detailed version info', - duration: Date.now() - versionStart - }); - return { checks }; - } - } catch (authError) { - const error = authError instanceof Error ? authError.message : 'Authentication error'; - let details = error; - - if (error.includes('Unauthorized') || error.includes('401')) { - details = - 'Authentication failed. Check if the token/certificate in kubeconfig is valid and not expired.'; - } else if (error.includes('Forbidden') || error.includes('403')) { - details = - 'Authorization failed. The user/service account does not have permission to list namespaces. Gyre requires at least namespace listing permissions.'; - } else if (error.includes('certificate') || error.includes('x509')) { - details = 'Certificate error. Check if the CA certificate is valid and matches the server.'; - } - - const isAuthFailure = - error.includes('Unauthorized') || - error.includes('401') || - error.includes('certificate') || - error.includes('x509'); - const sanitizedDetails = sanitizeK8sErrorMessage(details); - - checks.push({ - name: isAuthFailure ? 'Authentication' : 'Authorization', - passed: false, - message: isAuthFailure ? 'Authentication failed' : 'Authorization failed', - details: sanitizedDetails, - duration: Date.now() - authStart - }); - return { checks, error: sanitizedDetails }; - } -} - -/** - * Test cluster connection with detailed health diagnostics - */ -export async function testClusterConnection(id: string): Promise { - const cluster = await getClusterById(id); - const clusterName = cluster?.name || 'Unknown'; - const checks: HealthCheckResult[] = []; - - async function fail( - details: string | undefined, - extraChecks?: HealthCheckResult[] - ): Promise { - if (cluster) await updateCluster(id, { lastError: details }); - return { - connected: false, - clusterName, - checks: [...checks, ...(extraChecks ?? [])], - error: details, - timestamp: new Date() - }; - } - - try { - const kubeconfig = await getClusterKubeconfig(id); - if (!kubeconfig) { - const error = 'Kubeconfig not found or failed to decrypt'; - return await fail(error, [ - { - name: 'Kubeconfig Access', - passed: false, - message: 'Failed to retrieve kubeconfig', - details: error - } - ]); - } - - const { check: parseCheck, kc } = checkKubeconfigParse(kubeconfig); - checks.push(parseCheck); - if (!parseCheck.passed || !kc) { - return await fail(parseCheck.details); - } - - let reachabilityCheck: HealthCheckResult; - try { - reachabilityCheck = await checkApiReachability(kc); - } catch { - // Server responded with an auth/cert/authz error — it is reachable, so let - // checkAuthAndVersion produce the proper diagnostic instead of a network failure - const authResult = await checkAuthAndVersion(kc); - checks.push(...authResult.checks); - return await fail(authResult.error); - } - checks.push(reachabilityCheck); - if (!reachabilityCheck.passed) { - return await fail(reachabilityCheck.details); - } - - const authResult = await checkAuthAndVersion(kc); - checks.push(...authResult.checks); - if (authResult.error) { - return await fail(authResult.error); - } - - if (cluster) await updateCluster(id, { lastConnectedAt: new Date(), lastError: null }); - return { - connected: true, - clusterName, - kubernetesVersion: authResult.version, - checks, - timestamp: new Date() - }; - } catch (unexpectedError) { - const error = unexpectedError instanceof Error ? unexpectedError.message : 'Unexpected error'; - const sanitizedError = sanitizeK8sErrorMessage(error); - if (cluster) await updateCluster(id, { lastError: sanitizedError }); - return { connected: false, clusterName, checks, error: sanitizedError, timestamp: new Date() }; - } -} - -/** - * Migrate all kubeconfigs to the new AES-256-GCM (v2) format - */ -export async function migrateKubeconfigs(): Promise<{ migrated: number; failed: number }> { - const db = getDbSync(); - const allClusters = await getAllClusters(); - let migratedCount = 0; - let failed = 0; - - for (const cluster of allClusters) { - if (cluster.kubeconfigEncrypted && !cluster.kubeconfigEncrypted.startsWith('v2:')) { - try { - // Decrypt using legacy XOR format - const plaintext = decryptLegacyXorKubeconfig(cluster.kubeconfigEncrypted); - - // Validate the decrypted content before overwriting the stored ciphertext. - // XOR decryption with the wrong key produces garbled bytes that would pass - // re-encryption silently, destroying the original ciphertext permanently. - let parsed: unknown; - try { - parsed = yaml.load(plaintext); - } catch { - logger.error( - `Skipping migration for cluster ${cluster.name}: decrypted content is not valid YAML — original ciphertext preserved` - ); - failed++; - continue; - } - if ( - parsed === null || - typeof parsed !== 'object' || - !('apiVersion' in parsed) || - !('clusters' in parsed) || - !('contexts' in parsed) - ) { - logger.error( - `Skipping migration for cluster ${cluster.name}: decrypted content is missing required kubeconfig fields — original ciphertext preserved` - ); - failed++; - continue; - } - - // Re-encrypt using new v2 format - const reEncrypted = encryptKubeconfig(plaintext); - - await db - .update(clusters) - .set({ - kubeconfigEncrypted: reEncrypted, - updatedAt: new Date() - }) - .where(eq(clusters.id, cluster.id)); - - migratedCount++; - } catch (error) { - logger.error(error, `Failed to migrate kubeconfig for cluster ${cluster.name}:`); - failed++; - } - } - } - - return { migrated: migratedCount, failed }; -} - -// Exported for testing only -export { decryptKubeconfig as _decryptKubeconfig }; -export function _resetEncryptionKeyCache(): void { - _encryptionKey = null; -} - -export type { NewCluster, NewClusterContext }; +export * from './clusters/encryption.js'; +export * from './clusters/kubeconfig.js'; +export * from './clusters/repository.js'; +export * from './clusters/selection.js'; +export * from './clusters/health.js'; +export * from './clusters/migration.js'; diff --git a/src/lib/server/clusters/encryption.ts b/src/lib/server/clusters/encryption.ts new file mode 100644 index 00000000..8e106a58 --- /dev/null +++ b/src/lib/server/clusters/encryption.ts @@ -0,0 +1,136 @@ +import { logger } from '../logger.js'; +import crypto from 'node:crypto'; + +/** + * Get the encryption key for kubeconfigs from environment. + * In production, this MUST be set via GYRE_ENCRYPTION_KEY env var. + * For development, a default key is used. + */ +function getEncryptionKey(): string { + const key = process.env.GYRE_ENCRYPTION_KEY; + const isProd = process.env.NODE_ENV === 'production'; + + if (!key) { + if (isProd) { + throw new Error( + 'GYRE_ENCRYPTION_KEY must be set in production! ' + + 'Please set it to a 64-character hexadecimal string.' + ); + } + const devKey = crypto.randomBytes(32).toString('hex'); + logger.warn( + '⚠️ GYRE_ENCRYPTION_KEY not set! Using ephemeral random key. Encrypted kubeconfigs will be unreadable after restart. Set GYRE_ENCRYPTION_KEY to persist.' + ); + return devKey; + } + + // Validate key format (should be 64 hex characters = 32 bytes) + if (!/^[0-9a-f]{64}$/i.test(key)) { + throw new Error( + 'GYRE_ENCRYPTION_KEY must be 64 hexadecimal characters (32 bytes). Generate with: openssl rand -hex 32' + ); + } + + return key; +} + +let _encryptionKey: string | null = null; + +function getEncryptionKeyLazy(): string { + if (!_encryptionKey) { + _encryptionKey = getEncryptionKey(); + } + return _encryptionKey; +} + +/** + * Check if the encryption key is the insecure development default. + */ +export function isUsingDevelopmentKey(): boolean { + return !process.env.GYRE_ENCRYPTION_KEY; +} + +/** + * Validate that encryption/decryption works correctly. + */ +export function testEncryption(): boolean { + try { + const testSecret = 'test-kubeconfig-' + crypto.randomUUID(); + const encrypted = encryptKubeconfig(testSecret); + const decrypted = decryptKubeconfig(encrypted); + return testSecret === decrypted; + } catch { + return false; + } +} + +const ALGORITHM = 'aes-256-gcm'; + +/** + * Encrypt kubeconfig string using AES-256-GCM + * Format: v2:iv:ciphertext:authTag (all hex except v2 prefix) + */ +export function encryptKubeconfig(kubeconfig: string): string { + const iv = crypto.randomBytes(16); + const key = Buffer.from(getEncryptionKeyLazy(), 'hex'); // We validated it's 32 bytes hex + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(kubeconfig, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Return format: v2:iv:ciphertext:authTag + return `v2:${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`; +} + +/** + * Decrypt kubeconfig encrypted with legacy XOR cipher. + * Used only by migrateKubeconfigs() to read pre-migration records. + */ +export function decryptLegacyXorKubeconfig(encrypted: string): string { + const buffer = Buffer.from(encrypted, 'base64'); + const decrypted = Buffer.alloc(buffer.length); + const key = getEncryptionKeyLazy(); + for (let i = 0; i < buffer.length; i++) { + decrypted[i] = buffer[i] ^ key.charCodeAt(i % key.length); + } + return decrypted.toString('utf-8'); +} + +/** + * Decrypt kubeconfig string. + * Only AES-256-GCM (v2) format is supported. + */ +export function decryptKubeconfig(encrypted: string): string { + // Check if it's the new v2 format + if (encrypted.startsWith('v2:')) { + const parts = encrypted.split(':'); + if (parts.length !== 4) { + throw new Error('Invalid v2 encrypted kubeconfig format'); + } + + const [, ivHex, ciphertext, authTagHex] = parts; + const key = Buffer.from(getEncryptionKeyLazy(), 'hex'); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + throw new Error( + 'Unsupported kubeconfig encryption format: only v2 (AES-256-GCM) is supported. ' + + 'Run migrateKubeconfigs() to upgrade any legacy records.' + ); +} + +export { decryptKubeconfig as _decryptKubeconfig }; +export function _resetEncryptionKeyCache(): void { + _encryptionKey = null; +} diff --git a/src/lib/server/clusters/health.ts b/src/lib/server/clusters/health.ts new file mode 100644 index 00000000..e2123036 --- /dev/null +++ b/src/lib/server/clusters/health.ts @@ -0,0 +1,275 @@ +import * as k8s from '@kubernetes/client-node'; +import { sanitizeK8sErrorMessage } from '../kubernetes/errors.js'; +import { makeApiClientWithTimeout } from '../kubernetes/client-factory.js'; +import { OPERATION_TIMEOUTS } from '../kubernetes/timeouts.js'; +import { getClusterById, getClusterKubeconfig, updateCluster } from './repository.js'; + +/** + * Health check result for a single diagnostic test + */ +export interface HealthCheckResult { + name: string; + passed: boolean; + message: string; + details?: string; + duration?: number; +} + +/** + * Detailed cluster connection test result + */ +export interface ClusterHealthCheck { + connected: boolean; + clusterName: string; + kubernetesVersion?: string; + checks: HealthCheckResult[]; + error?: string; + timestamp: Date; +} + +function checkKubeconfigParse(kubeconfig: string): { + check: HealthCheckResult; + kc?: k8s.KubeConfig; +} { + const kc = new k8s.KubeConfig(); + const start = Date.now(); + try { + kc.loadFromString(kubeconfig); + return { + check: { + name: 'Kubeconfig Parse', + passed: true, + message: 'Kubeconfig is valid YAML/JSON', + duration: Date.now() - start + }, + kc + }; + } catch (parseError) { + const error = parseError instanceof Error ? parseError.message : 'Invalid kubeconfig format'; + return { + check: { + name: 'Kubeconfig Parse', + passed: false, + message: 'Failed to parse kubeconfig', + details: sanitizeK8sErrorMessage(error), + duration: Date.now() - start + } + }; + } +} + +async function checkApiReachability(kc: k8s.KubeConfig): Promise { + const start = Date.now(); + try { + const coreApi = makeApiClientWithTimeout(kc, k8s.CoreV1Api, OPERATION_TIMEOUTS.get); + await coreApi.getAPIResources(); + return { + name: 'API Server Reachability', + passed: true, + message: 'Successfully connected to Kubernetes API server', + duration: Date.now() - start + }; + } catch (networkError) { + const error = networkError instanceof Error ? networkError.message : 'Network error'; + + // Auth/cert/authz errors must bubble up to checkAuthAndVersion for proper diagnosis + if ( + error.includes('Unauthorized') || + error.includes('401') || + error.includes('Forbidden') || + error.includes('403') || + error.includes('certificate') || + error.includes('x509') + ) { + throw networkError; + } + + let details = error; + if (error.includes('ENOTFOUND') || error.includes('getaddrinfo')) { + details = 'DNS resolution failed. Check if the server address in kubeconfig is correct.'; + } else if (error.includes('ECONNREFUSED') || error.includes('ECONNRESET')) { + details = 'Connection refused. Check if the Kubernetes API server is running and accessible.'; + } else if (error.includes('ETIMEDOUT') || error.includes('timeout')) { + details = 'Connection timed out. Check network connectivity and firewall rules.'; + } + + return { + name: 'API Server Reachability', + passed: false, + message: 'Failed to reach Kubernetes API server', + details: sanitizeK8sErrorMessage(details), + duration: Date.now() - start + }; + } +} + +async function checkAuthAndVersion( + kc: k8s.KubeConfig +): Promise<{ checks: HealthCheckResult[]; version?: string; error?: string }> { + const authStart = Date.now(); + const checks: HealthCheckResult[] = []; + + try { + const coreApi = makeApiClientWithTimeout(kc, k8s.CoreV1Api, OPERATION_TIMEOUTS.list); + await coreApi.listNamespace({ limit: 1 }); + + const currentUser = kc.getCurrentUser(); + const userInfo = currentUser ? `User: ${currentUser.name}` : 'ServiceAccount'; + + checks.push({ + name: 'Authentication', + passed: true, + message: 'Authentication successful', + details: userInfo, + duration: Date.now() - authStart + }); + checks.push({ + name: 'Authorization', + passed: true, + message: 'Successfully listed namespaces', + details: 'Namespace access confirmed', + duration: Date.now() - authStart + }); + + // Version check is optional + const versionStart = Date.now(); + try { + const versionApi = makeApiClientWithTimeout(kc, k8s.VersionApi, OPERATION_TIMEOUTS.get); + const versionResponse = await versionApi.getCode(); + const version = versionResponse.gitVersion; + checks.push({ + name: 'Kubernetes Version', + passed: true, + message: `Cluster version detected: ${version}`, + duration: Date.now() - versionStart + }); + return { checks, version }; + } catch { + checks.push({ + name: 'Kubernetes Version', + passed: false, + message: 'Connected, but failed to retrieve detailed version info', + duration: Date.now() - versionStart + }); + return { checks }; + } + } catch (authError) { + const error = authError instanceof Error ? authError.message : 'Authentication error'; + let details = error; + + if (error.includes('Unauthorized') || error.includes('401')) { + details = + 'Authentication failed. Check if the token/certificate in kubeconfig is valid and not expired.'; + } else if (error.includes('Forbidden') || error.includes('403')) { + details = + 'Authorization failed. The user/service account does not have permission to list namespaces. Gyre requires at least namespace listing permissions.'; + } else if (error.includes('certificate') || error.includes('x509')) { + details = 'Certificate error. Check if the CA certificate is valid and matches the server.'; + } + + const isAuthFailure = + error.includes('Unauthorized') || + error.includes('401') || + error.includes('certificate') || + error.includes('x509'); + const sanitizedDetails = sanitizeK8sErrorMessage(details); + + checks.push({ + name: isAuthFailure ? 'Authentication' : 'Authorization', + passed: false, + message: isAuthFailure ? 'Authentication failed' : 'Authorization failed', + details: sanitizedDetails, + duration: Date.now() - authStart + }); + return { checks, error: sanitizedDetails }; + } +} + +/** + * Test cluster connection with detailed health diagnostics + */ +export async function testClusterConnection(id: string): Promise { + const cluster = await getClusterById(id); + const clusterName = cluster?.name || 'Unknown'; + const checks: HealthCheckResult[] = []; + + async function fail( + details: string | undefined, + extraChecks?: HealthCheckResult[] + ): Promise { + if (cluster) await updateCluster(id, { lastError: details }); + return { + connected: false, + clusterName, + checks: [...checks, ...(extraChecks ?? [])], + error: details, + timestamp: new Date() + }; + } + + try { + const kubeconfig = await getClusterKubeconfig(id); + if (!kubeconfig) { + const error = 'Kubeconfig not found or failed to decrypt'; + return await fail(error, [ + { + name: 'Kubeconfig Access', + passed: false, + message: 'Failed to retrieve kubeconfig', + details: error + } + ]); + } + + const { check: parseCheck, kc } = checkKubeconfigParse(kubeconfig); + checks.push(parseCheck); + if (!parseCheck.passed || !kc) { + return await fail(parseCheck.details); + } + + let reachabilityCheck: HealthCheckResult; + try { + reachabilityCheck = await checkApiReachability(kc); + } catch { + // Server responded with an auth/cert/authz error — it is reachable, so let + // checkAuthAndVersion produce the proper diagnostic instead of a network failure + const authResult = await checkAuthAndVersion(kc); + checks.push(...authResult.checks); + if (authResult.error) { + return await fail(authResult.error); + } + if (cluster) await updateCluster(id, { lastConnectedAt: new Date(), lastError: null }); + return { + connected: true, + clusterName, + kubernetesVersion: authResult.version, + checks, + timestamp: new Date() + }; + } + checks.push(reachabilityCheck); + if (!reachabilityCheck.passed) { + return await fail(reachabilityCheck.details); + } + + const authResult = await checkAuthAndVersion(kc); + checks.push(...authResult.checks); + if (authResult.error) { + return await fail(authResult.error); + } + + if (cluster) await updateCluster(id, { lastConnectedAt: new Date(), lastError: null }); + return { + connected: true, + clusterName, + kubernetesVersion: authResult.version, + checks, + timestamp: new Date() + }; + } catch (unexpectedError) { + const error = unexpectedError instanceof Error ? unexpectedError.message : 'Unexpected error'; + const sanitizedError = sanitizeK8sErrorMessage(error); + if (cluster) await updateCluster(id, { lastError: sanitizedError }); + return { connected: false, clusterName, checks, error: sanitizedError, timestamp: new Date() }; + } +} diff --git a/src/lib/server/clusters/kubeconfig.ts b/src/lib/server/clusters/kubeconfig.ts new file mode 100644 index 00000000..ba6e2c8f --- /dev/null +++ b/src/lib/server/clusters/kubeconfig.ts @@ -0,0 +1,24 @@ +import { logger } from '../logger.js'; +import * as k8s from '@kubernetes/client-node'; + +/** + * Parse kubeconfig and extract contexts + */ +export function parseKubeconfig(kubeconfig: string): { + contexts: string[]; + currentContext: string | null; +} { + try { + const kc = new k8s.KubeConfig(); + kc.loadFromString(kubeconfig); + + const contexts = kc.getContexts().map((ctx) => ctx.name); + const currentContext = kc.getCurrentContext(); + + return { contexts, currentContext }; + } catch (err) { + const message = err instanceof Error ? `${err.name}: ${err.message}` : 'unknown error'; + logger.error(`Failed to parse kubeconfig: ${message}`); + return { contexts: [], currentContext: null }; + } +} diff --git a/src/lib/server/clusters/migration.ts b/src/lib/server/clusters/migration.ts new file mode 100644 index 00000000..ea468cf7 --- /dev/null +++ b/src/lib/server/clusters/migration.ts @@ -0,0 +1,85 @@ +import { logger } from '../logger.js'; +import { and, eq } from 'drizzle-orm'; +import yaml from 'js-yaml'; +import { getDbSync } from '../db/index.js'; +import { clusters } from '../db/schema.js'; +import { decryptLegacyXorKubeconfig, encryptKubeconfig } from './encryption.js'; +import { getAllClusters } from './repository.js'; + +/** + * Migrate all kubeconfigs to the new AES-256-GCM (v2) format + */ +export async function migrateKubeconfigs(): Promise<{ migrated: number; failed: number }> { + const db = getDbSync(); + const allClusters = await getAllClusters(); + let migratedCount = 0; + let failed = 0; + + for (const cluster of allClusters) { + if (cluster.kubeconfigEncrypted && !cluster.kubeconfigEncrypted.startsWith('v2:')) { + try { + // Decrypt using legacy XOR format + const plaintext = decryptLegacyXorKubeconfig(cluster.kubeconfigEncrypted); + + // Validate the decrypted content before overwriting the stored ciphertext. + // XOR decryption with the wrong key produces garbled bytes that would pass + // re-encryption silently, destroying the original ciphertext permanently. + let parsed: unknown; + try { + parsed = yaml.load(plaintext); + } catch { + logger.error( + `Skipping migration for cluster ${cluster.name}: decrypted content is not valid YAML — original ciphertext preserved` + ); + failed++; + continue; + } + if ( + parsed === null || + typeof parsed !== 'object' || + !('apiVersion' in parsed) || + !('clusters' in parsed) || + !('contexts' in parsed) + ) { + logger.error( + `Skipping migration for cluster ${cluster.name}: decrypted content is missing required kubeconfig fields — original ciphertext preserved` + ); + failed++; + continue; + } + + // Re-encrypt using new v2 format + const reEncrypted = encryptKubeconfig(plaintext); + + const updatedRows = await db + .update(clusters) + .set({ + kubeconfigEncrypted: reEncrypted, + updatedAt: new Date() + }) + .where( + and( + eq(clusters.id, cluster.id), + eq(clusters.kubeconfigEncrypted, cluster.kubeconfigEncrypted) + ) + ) + .returning({ id: clusters.id }); + + if (updatedRows.length === 0) { + logger.warn( + `Skipping migration for cluster ${cluster.name}: kubeconfig changed during migration` + ); + failed++; + continue; + } + + migratedCount++; + } catch (error) { + logger.error(error, `Failed to migrate kubeconfig for cluster ${cluster.name}:`); + failed++; + } + } + } + + return { migrated: migratedCount, failed }; +} diff --git a/src/lib/server/clusters/repository.ts b/src/lib/server/clusters/repository.ts new file mode 100644 index 00000000..8272b833 --- /dev/null +++ b/src/lib/server/clusters/repository.ts @@ -0,0 +1,197 @@ +import { IN_CLUSTER_ID, type ClusterOption } from '$lib/clusters/identity.js'; +import crypto from 'node:crypto'; +import { desc, eq, or, sql } from 'drizzle-orm'; +import { getDb } from '../db/index.js'; +import { getPaginatedItems, sanitizeSearchInput } from '../db/utils.js'; +import { + clusterContexts, + clusters, + type NewCluster, + type NewClusterContext +} from '../db/schema.js'; +import { encryptKubeconfig, decryptKubeconfig } from './encryption.js'; +import { parseKubeconfig } from './kubeconfig.js'; + +/** + * Create a new cluster + */ +export async function createCluster(params: { + name: string; + description?: string; + kubeconfig: string; + isLocal: boolean; +}): Promise { + const db = await getDb(); + + const id = crypto.randomUUID(); + const { contexts, currentContext } = parseKubeconfig(params.kubeconfig); + const uniqueContexts = [...new Set(contexts)]; + if (uniqueContexts.length === 0) { + throw new Error('Invalid kubeconfig: no contexts found'); + } + const encryptedKubeconfig = encryptKubeconfig(params.kubeconfig); + + // Create cluster + const newCluster: NewCluster = { + id, + name: params.name, + description: params.description || null, + kubeconfigEncrypted: encryptedKubeconfig, + isActive: true, + isLocal: params.isLocal, + contextCount: uniqueContexts.length, + lastConnectedAt: null, + lastError: null + }; + const contextRecords: NewClusterContext[] = uniqueContexts.map((ctxName) => ({ + id: crypto.randomUUID(), + clusterId: id, + contextName: ctxName, + isCurrent: ctxName === currentContext, + server: null, + namespaceRestrictions: null + })); + + db.transaction((tx) => { + tx.insert(clusters).values(newCluster).run(); + if (contextRecords.length > 0) { + tx.insert(clusterContexts).values(contextRecords).run(); + } + }); + + const cluster = await db.query.clusters.findFirst({ + where: eq(clusters.id, id) + }); + + if (!cluster) { + throw new Error('Failed to create cluster'); + } + + return cluster; +} + +/** + * Get all clusters + */ +export async function getAllClusters(): Promise<(typeof clusters.$inferSelect)[]> { + const db = await getDb(); + return db.query.clusters.findMany({ + orderBy: [desc(clusters.createdAt)] + }); +} + +/** + * Get selectable cluster identities for UI/API selection. + * Kubeconfig context names are diagnostic metadata only; uploaded clusters are + * selected by their stable clusters.id values. + */ +export async function getSelectableClusters( + currentContext?: string | null +): Promise { + const uploadedClusters = (await getAllClusters()).filter( + (cluster) => cluster.isActive && cluster.isLocal + ); + + return [ + { + id: IN_CLUSTER_ID, + name: 'In-cluster', + description: 'Runtime Kubernetes configuration', + source: 'in-cluster', + isActive: true, + currentContext + }, + ...uploadedClusters.map((cluster) => ({ + id: cluster.id, + name: cluster.name, + description: cluster.description, + source: 'uploaded' as const, + isActive: cluster.isActive, + currentContext: null + })) + ]; +} + +/** + * Get clusters with pagination and search + */ +export async function getAllClustersPaginated(options?: { + search?: string; + limit?: number; + offset?: number; +}): Promise<{ clusters: (typeof clusters.$inferSelect)[]; total: number }> { + const result = await getPaginatedItems( + clusters, + (db) => db.query.clusters, + options, + (search) => { + const sanitized = sanitizeSearchInput(search); + const pattern = `%${sanitized}%`; + return or( + sql`${clusters.name} LIKE ${pattern} ESCAPE '\\'`, + sql`${clusters.description} LIKE ${pattern} ESCAPE '\\'` + ); + } + ); + + return { clusters: result.items, total: result.total }; +} + +/** + * Get cluster by ID + */ +export async function getClusterById(id: string): Promise { + const db = await getDb(); + const cluster = await db.query.clusters.findFirst({ + where: eq(clusters.id, id) + }); + return cluster || null; +} + +/** + * Update cluster + */ +export async function updateCluster( + id: string, + updates: Partial< + Pick + > +): Promise { + const db = await getDb(); + + await db + .update(clusters) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(clusters.id, id)); + + return getClusterById(id); +} + +/** + * Delete cluster + */ +export async function deleteCluster(id: string): Promise { + const db = await getDb(); + + await db.transaction((tx) => { + // Delete contexts first (cascade should handle this but being explicit) + tx.delete(clusterContexts).where(eq(clusterContexts.clusterId, id)).run(); + + // Delete cluster + tx.delete(clusters).where(eq(clusters.id, id)).run(); + }); +} + +/** + * Get decrypted kubeconfig + */ +export async function getClusterKubeconfig(id: string): Promise { + const cluster = await getClusterById(id); + if (!cluster || !cluster.kubeconfigEncrypted) { + return null; + } + + return decryptKubeconfig(cluster.kubeconfigEncrypted); +} + +export type { NewCluster, NewClusterContext }; diff --git a/src/lib/server/clusters/selection.ts b/src/lib/server/clusters/selection.ts new file mode 100644 index 00000000..36c07349 --- /dev/null +++ b/src/lib/server/clusters/selection.ts @@ -0,0 +1,62 @@ +import { IN_CLUSTER_ID, normalizeClusterId, type ClusterOption } from '$lib/clusters/identity.js'; +import { getClusterById, getSelectableClusters } from './repository.js'; +import type { Cookies } from '@sveltejs/kit'; + +export const CLUSTER_SELECTION_COOKIE = 'gyre_cluster'; + +const COOKIE_OPTIONS = { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 30 +}; + +export async function validateSelectableClusterId(clusterId: string): Promise { + const normalizedId = normalizeClusterId(clusterId); + if (normalizedId === IN_CLUSTER_ID) return IN_CLUSTER_ID; + + const cluster = await getClusterById(normalizedId); + if (!cluster || !cluster.isActive) { + throw new Error(`Cluster "${normalizedId}" is not selectable`); + } + + return normalizedId; +} + +export async function resolveClusterSelectionFromCookie(cookies: Cookies): Promise { + const cookieValue = cookies.get(CLUSTER_SELECTION_COOKIE); + if (!cookieValue) return IN_CLUSTER_ID; + + try { + return await validateSelectableClusterId(cookieValue); + } catch { + clearClusterSelectionCookie(cookies); + return IN_CLUSTER_ID; + } +} + +export function setClusterSelectionCookie(cookies: Cookies, clusterId: string): void { + const normalizedId = normalizeClusterId(clusterId); + cookies.set(CLUSTER_SELECTION_COOKIE, normalizedId, COOKIE_OPTIONS); +} + +export function clearClusterSelectionCookie(cookies: Cookies): void { + cookies.delete(CLUSTER_SELECTION_COOKIE, { path: '/' }); +} + +export async function getClusterSelectionPayload(currentClusterId: string): Promise<{ + currentCluster: ClusterOption; + currentClusterId: string; + selectableClusters: ClusterOption[]; +}> { + const selectableClusters = await getSelectableClusters(); + const currentCluster = + selectableClusters.find((cluster) => cluster.id === currentClusterId) ?? selectableClusters[0]; + + return { + currentCluster, + currentClusterId: currentCluster.id, + selectableClusters + }; +} diff --git a/src/lib/server/events.ts b/src/lib/server/events.ts index 46c820f7..866a1bd3 100644 --- a/src/lib/server/events.ts +++ b/src/lib/server/events.ts @@ -1,497 +1,5 @@ -import { logger } from './logger.js'; -import { IN_CLUSTER_ID } from '$lib/clusters/identity.js'; -import { listFluxResources } from './kubernetes/client.js'; -import type { FluxResourceType } from './kubernetes/flux/resources.js'; -import type { FluxResource, K8sCondition } from './kubernetes/flux/types.js'; -import { - resourcePollsTotal, - resourceUpdatesTotal, - sseSubscribersGauge, - activeWorkersGauge, - fluxResourceStatusGauge -} from './metrics.js'; -import { captureReconciliation } from './kubernetes/flux/reconciliation-tracker.js'; -import { SETTLING_PERIOD_MS, POLL_INTERVAL_MS, HEARTBEAT_INTERVAL_MS } from './config/constants.js'; - -function normalizeError(value: unknown): Error | { message: string; value: unknown } { - if (value instanceof Error) return value; - if (typeof value === 'string') return new Error(value); - return { message: 'Non-Error rejection', value }; -} - -// Resource types to watch -const WATCH_RESOURCES: FluxResourceType[] = [ - 'GitRepository', - 'HelmRepository', - 'Kustomization', - 'HelmRelease' -]; - -export interface SSEEvent { - type: 'CONNECTED' | 'ADDED' | 'MODIFIED' | 'DELETED' | 'HEARTBEAT' | 'SHUTDOWN'; - clusterId?: string; - resourceType?: string; - resource?: unknown; - message?: string; - timestamp: string; - serverSessionId?: string; - reason?: string; -} - -// Stable identifier for this server process lifetime; changes on restart -const SERVER_SESSION_ID = Date.now().toString(36); - -type Subscriber = (event: SSEEvent) => void; - -interface ClusterContext { - clusterId: string; - subscribers: Set; - isActive: boolean; - pollTimeout: NodeJS.Timeout | null; - heartbeatInterval: NodeJS.Timeout | null; - inflightPollPromise: Promise | null; - lastStates: Map; - lastNotificationStates: Map; - resourceFirstSeen: Map; -} - -// Map of active polling workers per cluster -const activeWorkers = new Map(); - -// Shutdown flag to prevent new subscriptions during shutdown -let isShuttingDown = false; - -/** - * Mark the event bus as shutting down to prevent new subscriptions - */ -export function setEventBusShuttingDown(): void { - isShuttingDown = true; -} - -/** - * Close all active event streams (used during graceful shutdown) - */ -export async function closeAllEventStreams() { - logger.info('[EventBus] Shutting down all event streams...'); - isShuttingDown = true; - - // Collect inflight promises before touching any context so the broadcast loop - // below is not delayed by sequential awaits. - const inflightPromises: Array<[string, Promise]> = []; - for (const [clusterId, context] of Array.from(activeWorkers.entries())) { - if (context.inflightPollPromise) { - inflightPromises.push([clusterId, context.inflightPollPromise]); - } - - // Broadcast SHUTDOWN to all subscribers - this will trigger their unsubscribe() - // which will call stopWorker and remove from activeWorkers - broadcast(context, { - type: 'SHUTDOWN', - clusterId, - timestamp: new Date().toISOString(), - reason: 'server_shutdown' - }); - // Explicitly call stopWorker to guarantee timer cleanup even if a subscriber throws. - // It is safe if stopWorker is called twice (idempotent check for null intervals). - stopWorker(context, 'server shutdown'); - - // Subscribers are cleared by their unsubscribe callbacks during broadcast. - // The guard exists because unsubscribe() may have already called activeWorkers.delete(clusterId) - // during the broadcast, modifying the Map during live iteration. - if (activeWorkers.has(clusterId)) { - context.subscribers.clear(); - } - } - - // Await all inflight polls concurrently now that all workers are stopped. - const pollResults = await Promise.allSettled(inflightPromises.map(([, p]) => p)); - pollResults.forEach((result, i) => { - if (result.status === 'rejected') { - logger.error( - { clusterId: inflightPromises[i][0], err: normalizeError(result.reason) }, - '[EventBus] Error awaiting poll' - ); - } - }); - activeWorkers.clear(); - // Reset metrics - activeWorkersGauge.set(0); - sseSubscribersGauge.reset(); -} - -/** - * Subscribe to events for a specific cluster - * @param clusterId - The cluster to watch - * @param subscriber - Callback for events - */ -export function subscribe(subscriber: Subscriber, clusterId: string = IN_CLUSTER_ID): () => void { - // Prevent new subscriptions during shutdown - if (isShuttingDown) { - logger.warn({ clusterId }, '[EventBus] Rejecting new subscription: shutting down'); - return () => {}; - } - - let context = activeWorkers.get(clusterId); - - if (!context) { - context = { - clusterId, - subscribers: new Set(), - isActive: false, - pollTimeout: null, - heartbeatInterval: null, - inflightPollPromise: null, - lastStates: new Map(), - lastNotificationStates: new Map(), - resourceFirstSeen: new Map() - }; - activeWorkers.set(clusterId, context); - } - - context.subscribers.add(subscriber); - sseSubscribersGauge.labels(clusterId).set(context.subscribers.size); - - // Initial connection message - subscriber({ - type: 'CONNECTED', - clusterId, - serverSessionId: SERVER_SESSION_ID, - message: `Connected to event stream for cluster: ${clusterId}`, - timestamp: new Date().toISOString() - }); - - if (!context.isActive) { - startWorker(context); - } - - return () => { - const ctx = activeWorkers.get(clusterId); - if (!ctx) return; - - ctx.subscribers.delete(subscriber); - sseSubscribersGauge.labels(clusterId).set(ctx.subscribers.size); - - if (ctx.subscribers.size === 0) { - stopWorker(ctx, 'no active subscribers'); - activeWorkers.delete(clusterId); - } - }; -} - -function startWorker(context: ClusterContext) { - if (context.isActive) return; - context.isActive = true; - activeWorkersGauge.set(activeWorkers.size); - logger.info({ clusterId: context.clusterId }, '[EventBus] Starting consolidated polling worker'); - - poll(context); - - context.heartbeatInterval = setInterval(() => { - broadcast(context, { - type: 'HEARTBEAT', - clusterId: context.clusterId, - timestamp: new Date().toISOString() - }); - }, HEARTBEAT_INTERVAL_MS); -} - -function stopWorker(context: ClusterContext, reason: string = 'no active subscribers') { - if (!context.isActive) return; - context.isActive = false; - activeWorkersGauge.set(Array.from(activeWorkers.values()).filter((w) => w.isActive).length); - if (context.pollTimeout) { - clearTimeout(context.pollTimeout); - context.pollTimeout = null; - } - if (context.heartbeatInterval) { - clearInterval(context.heartbeatInterval); - context.heartbeatInterval = null; - } - for (const key of context.lastStates.keys()) { - const [type, namespace, name] = key.split('/'); - fluxResourceStatusGauge.remove(context.clusterId, type, namespace, name, 'Ready'); - } - context.lastStates.clear(); - context.lastNotificationStates.clear(); - context.resourceFirstSeen.clear(); - logger.info( - { clusterId: context.clusterId, reason }, - '[EventBus] Stopping consolidated polling worker' - ); -} - -function broadcast(context: ClusterContext, event: SSEEvent) { - // The loop is fault-tolerant: if a subscriber callback throws (e.g. during SHUTDOWN due to a closed stream), - // it is caught and logged, ensuring remaining subscribers still receive the event. - for (const subscriber of context.subscribers) { - try { - subscriber(event); - } catch (err) { - if (event.type === 'SHUTDOWN') { - logger.debug( - { clusterId: context.clusterId, err: normalizeError(err) }, - '[EventBus] Error broadcasting SHUTDOWN to subscriber' - ); - } else { - logger.error( - { clusterId: context.clusterId, err: normalizeError(err) }, - '[EventBus] Error broadcasting to subscriber' - ); - } - } - } -} - -async function poll(context: ClusterContext) { - if (!context.isActive) return; - - let resolvePoll: () => void = () => {}; - const promise = new Promise((resolve) => { - resolvePoll = resolve; - }); - context.inflightPollPromise = promise; - - try { - for (const resourceType of WATCH_RESOURCES) { - try { - // Pass clusterId to listFluxResources to get resources from the correct cluster - const resourceList = await listFluxResources( - resourceType, - context.clusterId === IN_CLUSTER_ID ? undefined : context.clusterId - ); - - if (!context.isActive) return; - - resourcePollsTotal.labels(context.clusterId, resourceType, 'success').inc(); - - if (resourceList && resourceList.items) { - const currentMessageKeys = new Set(); - - for (const resource of resourceList.items) { - const key = `${resourceType}/${resource.metadata.namespace}/${resource.metadata.name}`; - currentMessageKeys.add(key); - - const conditions = resource.status?.conditions?.map((c: K8sCondition) => ({ - type: c.type, - status: c.status, - reason: c.reason, - message: c.message - })); - - const currentState = JSON.stringify({ - resourceVersion: resource.metadata?.resourceVersion, - generation: resource.metadata?.generation, - observedGeneration: resource.status?.observedGeneration - }); - - const readyCondition = conditions?.find((c: { type: string }) => c.type === 'Ready'); - - // Update resource status gauge - fluxResourceStatusGauge - .labels( - context.clusterId, - resourceType, - resource.metadata.namespace || 'unknown', - resource.metadata.name || 'unknown', - 'Ready' - ) - .set(readyCondition?.status === 'True' ? 1 : 0); - - const revision = getResourceRevision(resource); - - const notificationState = JSON.stringify({ - revision: revision, - readyStatus: readyCondition?.status, - readyReason: readyCondition?.reason, - messagePreview: readyCondition?.message?.substring(0, 100) || '' - }); - - const previousState = context.lastStates.get(key); - - const now = Date.now(); - // Only record firstSeen for resources not yet in lastStates. - // Resources in lastStates but missing from resourceFirstSeen had their - // entry pruned after settling — treat them as already settled. - if (!context.resourceFirstSeen.has(key) && !context.lastStates.has(key)) { - context.resourceFirstSeen.set(key, now); - } - const isSettled = context.resourceFirstSeen.has(key) - ? now - context.resourceFirstSeen.get(key)! > SETTLING_PERIOD_MS - : true; // no firstSeen entry + exists in lastStates = already settled - - if (!previousState) { - if (isSettled) { - resourceUpdatesTotal.labels(context.clusterId, resourceType, 'added').inc(); - - // Capture initial reconciliation history - try { - await captureReconciliation({ - resourceType, - namespace: resource.metadata.namespace || '', - name: resource.metadata.name || '', - clusterId: context.clusterId, - resource, - triggerType: 'automatic' - }); - } catch (err) { - logger.error( - { - err: normalizeError(err), - resourceType, - resourceName: resource.metadata.name, - namespace: resource.metadata.namespace - }, - '[EventBus] Failed to capture reconciliation history' - ); - // Don't fail event broadcast if history capture fails - } - - if (!context.isActive) return; - - broadcast(context, { - type: 'ADDED', - clusterId: context.clusterId, - resourceType, - resource: { - metadata: { - name: resource.metadata.name, - namespace: resource.metadata.namespace, - uid: resource.metadata.uid || 'unknown' - }, - status: resource.status - }, - timestamp: new Date().toISOString() - }); - } - context.lastNotificationStates.set(key, notificationState); - } else if (previousState && previousState !== currentState) { - const previousNotificationState = context.lastNotificationStates.get(key); - - if (!previousNotificationState || previousNotificationState !== notificationState) { - const prevState = previousNotificationState - ? JSON.parse(previousNotificationState) - : {}; - const currState = JSON.parse(notificationState); - - const revisionChanged = prevState.revision !== currState.revision; - const becameFailed = currState.readyStatus === 'False'; - const becameHealthy = - prevState.readyStatus === 'False' && currState.readyStatus === 'True'; - const isTransientState = - !currState.readyStatus || currState.readyStatus === 'Unknown'; - - const shouldNotify = revisionChanged || becameFailed || becameHealthy; - - if (shouldNotify && !isTransientState) { - resourceUpdatesTotal.labels(context.clusterId, resourceType, 'modified').inc(); - - // Capture reconciliation history - try { - await captureReconciliation({ - resourceType, - namespace: resource.metadata.namespace || '', - name: resource.metadata.name || '', - clusterId: context.clusterId, - resource, - triggerType: 'automatic' - }); - } catch (err) { - logger.error( - { - err: normalizeError(err), - resourceType, - resourceName: resource.metadata.name, - namespace: resource.metadata.namespace - }, - '[EventBus] Failed to capture reconciliation history' - ); - // Don't fail event broadcast if history capture fails - } - - if (!context.isActive) return; - - broadcast(context, { - type: 'MODIFIED', - clusterId: context.clusterId, - resourceType, - resource: { - metadata: { - name: resource.metadata.name, - namespace: resource.metadata.namespace, - uid: resource.metadata.uid || 'unknown' - }, - status: resource.status - }, - timestamp: new Date().toISOString() - }); - } - context.lastNotificationStates.set(key, notificationState); - } - } - - context.lastStates.set(key, currentState); - // Once settled and tracked in lastStates, firstSeen is no longer needed - if (isSettled) { - context.resourceFirstSeen.delete(key); - } - } - - for (const key of context.lastStates.keys()) { - if (key.startsWith(`${resourceType}/`) && !currentMessageKeys.has(key)) { - const [type, namespace, name] = key.split('/'); - - // Clear status gauge - fluxResourceStatusGauge.remove(context.clusterId, type, namespace, name, 'Ready'); - - resourceUpdatesTotal.labels(context.clusterId, type, 'deleted').inc(); - broadcast(context, { - type: 'DELETED', - clusterId: context.clusterId, - resourceType: type, - resource: { - metadata: { - name: name, - namespace: namespace, - uid: 'unknown' - } - }, - timestamp: new Date().toISOString() - }); - - context.lastStates.delete(key); - context.lastNotificationStates.delete(key); - context.resourceFirstSeen.delete(key); - } - } - } - } catch (err) { - resourcePollsTotal.labels(context.clusterId, resourceType, 'error').inc(); - logger.error( - { clusterId: context.clusterId, resourceType, err: normalizeError(err) }, - '[EventBus] Error polling resource type' - ); - } - } - } catch (err) { - logger.error( - { clusterId: context.clusterId, err: normalizeError(err) }, - '[EventBus] Critical error in poll loop' - ); - } finally { - resolvePoll!(); - context.inflightPollPromise = null; - } - - if (context.isActive) { - context.pollTimeout = setTimeout(() => poll(context), POLL_INTERVAL_MS); - } -} - -function getResourceRevision(resource: FluxResource): string { - return ( - resource.status?.lastAppliedRevision || - resource.status?.artifact?.revision || - resource.status?.lastAttemptedRevision || - '' - ); -} +export * from './events/types.js'; +export * from './events/state.js'; +export * from './events/bus.js'; +export * from './events/poller.js'; +export * from './events/shutdown.js'; diff --git a/src/lib/server/events/bus.ts b/src/lib/server/events/bus.ts new file mode 100644 index 00000000..cabe7dce --- /dev/null +++ b/src/lib/server/events/bus.ts @@ -0,0 +1,148 @@ +import { logger } from '../logger.js'; +import { IN_CLUSTER_ID, normalizeClusterId } from '$lib/clusters/identity.js'; +import { HEARTBEAT_INTERVAL_MS } from '../config/constants.js'; +import { activeWorkers, isEventBusShuttingDown, SERVER_SESSION_ID } from './state.js'; +import { activeWorkersGauge, fluxResourceStatusGauge, sseSubscribersGauge } from '../metrics.js'; +import { poll } from './poller.js'; +import { normalizeError, type ClusterContext, type SSEEvent, type Subscriber } from './types.js'; + +/** + * Subscribe to events for a specific cluster + * @param clusterId - The cluster to watch + * @param subscriber - Callback for events + */ +export function subscribe(subscriber: Subscriber, clusterId: string = IN_CLUSTER_ID): () => void { + const canonicalClusterId = normalizeClusterId(clusterId); + + // Prevent new subscriptions during shutdown + if (isEventBusShuttingDown()) { + logger.warn( + { clusterId: canonicalClusterId }, + '[EventBus] Rejecting new subscription: shutting down' + ); + return () => {}; + } + + let context = activeWorkers.get(canonicalClusterId); + + if (!context) { + context = { + clusterId: canonicalClusterId, + subscribers: new Set(), + isActive: false, + pollTimeout: null, + heartbeatInterval: null, + inflightPollPromise: null, + lastStates: new Map(), + lastNotificationStates: new Map(), + resourceFirstSeen: new Map() + }; + activeWorkers.set(canonicalClusterId, context); + } + + context.subscribers.add(subscriber); + sseSubscribersGauge.labels(canonicalClusterId).set(context.subscribers.size); + + // Initial connection message + try { + subscriber({ + type: 'CONNECTED', + clusterId: canonicalClusterId, + serverSessionId: SERVER_SESSION_ID, + message: `Connected to event stream for cluster: ${canonicalClusterId}`, + timestamp: new Date().toISOString() + }); + } catch (err) { + context.subscribers.delete(subscriber); + sseSubscribersGauge.labels(canonicalClusterId).set(context.subscribers.size); + if (context.subscribers.size === 0 && !context.isActive) { + activeWorkers.delete(canonicalClusterId); + } + logger.error( + { clusterId: canonicalClusterId, err: normalizeError(err) }, + '[EventBus] Error sending initial CONNECTED event' + ); + return () => {}; + } + + if (!context.isActive) { + startWorker(context); + } + + return () => { + const ctx = activeWorkers.get(canonicalClusterId); + if (!ctx) return; + + ctx.subscribers.delete(subscriber); + sseSubscribersGauge.labels(canonicalClusterId).set(ctx.subscribers.size); + + if (ctx.subscribers.size === 0) { + stopWorker(ctx, 'no active subscribers'); + activeWorkers.delete(canonicalClusterId); + } + }; +} + +export function startWorker(context: ClusterContext) { + if (context.isActive) return; + context.isActive = true; + activeWorkersGauge.set(activeWorkers.size); + logger.info({ clusterId: context.clusterId }, '[EventBus] Starting consolidated polling worker'); + + poll(context); + + context.heartbeatInterval = setInterval(() => { + broadcast(context, { + type: 'HEARTBEAT', + clusterId: context.clusterId, + timestamp: new Date().toISOString() + }); + }, HEARTBEAT_INTERVAL_MS); +} + +export function stopWorker(context: ClusterContext, reason: string = 'no active subscribers') { + if (!context.isActive) return; + context.isActive = false; + activeWorkersGauge.set(Array.from(activeWorkers.values()).filter((w) => w.isActive).length); + if (context.pollTimeout) { + clearTimeout(context.pollTimeout); + context.pollTimeout = null; + } + if (context.heartbeatInterval) { + clearInterval(context.heartbeatInterval); + context.heartbeatInterval = null; + } + for (const key of context.lastStates.keys()) { + const [type, namespace, name] = key.split('/'); + fluxResourceStatusGauge.remove(context.clusterId, type, namespace, name, 'Ready'); + } + context.lastStates.clear(); + context.lastNotificationStates.clear(); + context.resourceFirstSeen.clear(); + logger.info( + { clusterId: context.clusterId, reason }, + '[EventBus] Stopping consolidated polling worker' + ); +} + +export function broadcast(context: ClusterContext, event: SSEEvent) { + // The loop is fault-tolerant: if a subscriber callback throws (e.g. during SHUTDOWN due to a closed stream), + // it is caught and logged, ensuring remaining subscribers still receive the event. + for (const subscriber of context.subscribers) { + try { + subscriber(event); + } catch (err) { + if (event.type === 'SHUTDOWN') { + logger.debug( + { clusterId: context.clusterId, err: normalizeError(err) }, + '[EventBus] Error broadcasting SHUTDOWN to subscriber' + ); + } else { + logger.error( + { clusterId: context.clusterId, err: normalizeError(err) }, + '[EventBus] Error broadcasting to subscriber' + ); + } + } + } +} diff --git a/src/lib/server/events/poller.ts b/src/lib/server/events/poller.ts new file mode 100644 index 00000000..b5b64658 --- /dev/null +++ b/src/lib/server/events/poller.ts @@ -0,0 +1,286 @@ +import { logger } from '../logger.js'; +import { IN_CLUSTER_ID } from '$lib/clusters/identity.js'; +import { listFluxResources } from '../kubernetes/client.js'; +import type { FluxResourceType } from '../kubernetes/flux/resources.js'; +import type { FluxResource, K8sCondition } from '../kubernetes/flux/types.js'; +import { resourcePollsTotal, resourceUpdatesTotal, fluxResourceStatusGauge } from '../metrics.js'; +import { captureReconciliation } from '../kubernetes/flux/reconciliation-tracker.js'; +import { POLL_INTERVAL_MS, SETTLING_PERIOD_MS } from '../config/constants.js'; +import { broadcast } from './bus.js'; +import { normalizeError, type ClusterContext } from './types.js'; + +const WATCH_RESOURCES: FluxResourceType[] = [ + 'GitRepository', + 'HelmRepository', + 'Kustomization', + 'HelmRelease' +]; + +export async function poll(context: ClusterContext) { + if (!context.isActive) return; + + let resolvePoll: () => void = () => {}; + const promise = new Promise((resolve) => { + resolvePoll = resolve; + }); + context.inflightPollPromise = promise; + + try { + for (const resourceType of WATCH_RESOURCES) { + try { + // Pass clusterId to listFluxResources to get resources from the correct cluster + const resourceList = await listFluxResources( + resourceType, + context.clusterId === IN_CLUSTER_ID ? undefined : context.clusterId + ); + + if (!context.isActive) return; + + resourcePollsTotal.labels(context.clusterId, resourceType, 'success').inc(); + + if (resourceList && resourceList.items) { + const currentMessageKeys = new Set(); + + for (const resource of resourceList.items) { + const key = `${resourceType}/${resource.metadata.namespace}/${resource.metadata.name}`; + currentMessageKeys.add(key); + + const conditions = resource.status?.conditions?.map((c: K8sCondition) => ({ + type: c.type, + status: c.status, + reason: c.reason, + message: c.message + })); + + const currentState = JSON.stringify({ + resourceVersion: resource.metadata?.resourceVersion, + generation: resource.metadata?.generation, + observedGeneration: resource.status?.observedGeneration + }); + + const readyCondition = conditions?.find((c: { type: string }) => c.type === 'Ready'); + + // Update resource status gauge + fluxResourceStatusGauge + .labels( + context.clusterId, + resourceType, + resource.metadata.namespace || 'unknown', + resource.metadata.name || 'unknown', + 'Ready' + ) + .set(readyCondition?.status === 'True' ? 1 : 0); + + const revision = getResourceRevision(resource); + + const notificationState = JSON.stringify({ + revision: revision, + readyStatus: readyCondition?.status, + readyReason: readyCondition?.reason, + messagePreview: readyCondition?.message?.substring(0, 100) || '' + }); + + const previousState = context.lastStates.get(key); + + const now = Date.now(); + // Only record firstSeen for resources not yet in lastStates. + // Resources in lastStates but missing from resourceFirstSeen had their + // entry pruned after settling — treat them as already settled. + if (!context.resourceFirstSeen.has(key) && !context.lastStates.has(key)) { + context.resourceFirstSeen.set(key, now); + } + const isSettled = context.resourceFirstSeen.has(key) + ? now - context.resourceFirstSeen.get(key)! > SETTLING_PERIOD_MS + : true; // no firstSeen entry + exists in lastStates = already settled + + if (!previousState) { + if (isSettled) { + resourceUpdatesTotal.labels(context.clusterId, resourceType, 'added').inc(); + + // Capture initial reconciliation history + try { + await captureReconciliation({ + resourceType, + namespace: resource.metadata.namespace || '', + name: resource.metadata.name || '', + clusterId: context.clusterId, + resource, + triggerType: 'automatic' + }); + } catch (err) { + logger.error( + { + err: normalizeError(err), + resourceType, + resourceName: resource.metadata.name, + namespace: resource.metadata.namespace + }, + '[EventBus] Failed to capture reconciliation history' + ); + // Don't fail event broadcast if history capture fails + } + + if (!context.isActive) return; + + broadcast(context, { + type: 'ADDED', + clusterId: context.clusterId, + resourceType, + resource: { + metadata: { + name: resource.metadata.name, + namespace: resource.metadata.namespace, + uid: resource.metadata.uid || 'unknown' + }, + status: resource.status + }, + timestamp: new Date().toISOString() + }); + + context.lastStates.set(key, currentState); + context.lastNotificationStates.set(key, notificationState); + context.resourceFirstSeen.delete(key); + } + } else if (previousState && previousState !== currentState) { + const previousNotificationState = context.lastNotificationStates.get(key); + + if (!previousNotificationState || previousNotificationState !== notificationState) { + const prevState = previousNotificationState + ? JSON.parse(previousNotificationState) + : {}; + const currState = JSON.parse(notificationState); + + const revisionChanged = prevState.revision !== currState.revision; + const becameFailed = currState.readyStatus === 'False'; + const becameHealthy = + prevState.readyStatus === 'False' && currState.readyStatus === 'True'; + const isTransientState = + !currState.readyStatus || currState.readyStatus === 'Unknown'; + + const shouldNotify = revisionChanged || becameFailed || becameHealthy; + + if (shouldNotify && !isTransientState) { + resourceUpdatesTotal.labels(context.clusterId, resourceType, 'modified').inc(); + + // Capture reconciliation history + try { + await captureReconciliation({ + resourceType, + namespace: resource.metadata.namespace || '', + name: resource.metadata.name || '', + clusterId: context.clusterId, + resource, + triggerType: 'automatic' + }); + } catch (err) { + logger.error( + { + err: normalizeError(err), + resourceType, + resourceName: resource.metadata.name, + namespace: resource.metadata.namespace + }, + '[EventBus] Failed to capture reconciliation history' + ); + // Don't fail event broadcast if history capture fails + } + + if (!context.isActive) return; + + broadcast(context, { + type: 'MODIFIED', + clusterId: context.clusterId, + resourceType, + resource: { + metadata: { + name: resource.metadata.name, + namespace: resource.metadata.namespace, + uid: resource.metadata.uid || 'unknown' + }, + status: resource.status + }, + timestamp: new Date().toISOString() + }); + } + if (!isTransientState) { + context.lastNotificationStates.set(key, notificationState); + } + } + + context.lastStates.set(key, currentState); + } + } + + for (const key of Array.from(context.lastStates.keys())) { + if (key.startsWith(`${resourceType}/`) && !currentMessageKeys.has(key)) { + broadcastDeletedResource(context, key); + } + } + + for (const key of Array.from(context.resourceFirstSeen.keys())) { + if ( + key.startsWith(`${resourceType}/`) && + !currentMessageKeys.has(key) && + !context.lastStates.has(key) + ) { + broadcastDeletedResource(context, key); + } + } + } + } catch (err) { + resourcePollsTotal.labels(context.clusterId, resourceType, 'error').inc(); + logger.error( + { clusterId: context.clusterId, resourceType, err: normalizeError(err) }, + '[EventBus] Error polling resource type' + ); + } + } + } catch (err) { + logger.error( + { clusterId: context.clusterId, err: normalizeError(err) }, + '[EventBus] Critical error in poll loop' + ); + } finally { + resolvePoll!(); + context.inflightPollPromise = null; + } + + if (context.isActive) { + context.pollTimeout = setTimeout(() => poll(context), POLL_INTERVAL_MS); + } +} + +function broadcastDeletedResource(context: ClusterContext, key: string) { + const [type, namespace, name] = key.split('/'); + + // Clear status gauge + fluxResourceStatusGauge.remove(context.clusterId, type, namespace, name, 'Ready'); + + resourceUpdatesTotal.labels(context.clusterId, type, 'deleted').inc(); + broadcast(context, { + type: 'DELETED', + clusterId: context.clusterId, + resourceType: type, + resource: { + metadata: { + name: name, + namespace: namespace, + uid: 'unknown' + } + }, + timestamp: new Date().toISOString() + }); + + context.lastStates.delete(key); + context.lastNotificationStates.delete(key); + context.resourceFirstSeen.delete(key); +} + +function getResourceRevision(resource: FluxResource): string { + return ( + resource.status?.lastAppliedRevision || + resource.status?.artifact?.revision || + resource.status?.lastAttemptedRevision || + '' + ); +} diff --git a/src/lib/server/events/shutdown.ts b/src/lib/server/events/shutdown.ts new file mode 100644 index 00000000..0c6a052c --- /dev/null +++ b/src/lib/server/events/shutdown.ts @@ -0,0 +1,58 @@ +import { logger } from '../logger.js'; +import { activeWorkers, setEventBusShuttingDown } from './state.js'; +import { activeWorkersGauge, sseSubscribersGauge } from '../metrics.js'; +import { broadcast, stopWorker } from './bus.js'; +import { normalizeError } from './types.js'; + +export { setEventBusShuttingDown } from './state.js'; + +/** + * Mark the event bus as shutting down to prevent new subscriptions + */ +export async function closeAllEventStreams() { + logger.info('[EventBus] Shutting down all event streams...'); + setEventBusShuttingDown(); + + // Collect inflight promises before touching any context so the broadcast loop + // below is not delayed by sequential awaits. + const inflightPromises: Array<[string, Promise]> = []; + for (const [clusterId, context] of Array.from(activeWorkers.entries())) { + if (context.inflightPollPromise) { + inflightPromises.push([clusterId, context.inflightPollPromise]); + } + + // Broadcast SHUTDOWN to all subscribers - this will trigger their unsubscribe() + // which will call stopWorker and remove from activeWorkers + broadcast(context, { + type: 'SHUTDOWN', + clusterId, + timestamp: new Date().toISOString(), + reason: 'server_shutdown' + }); + // Explicitly call stopWorker to guarantee timer cleanup even if a subscriber throws. + // It is safe if stopWorker is called twice (idempotent check for null intervals). + stopWorker(context, 'server shutdown'); + + // Subscribers are cleared by their unsubscribe callbacks during broadcast. + // The guard exists because unsubscribe() may have already called activeWorkers.delete(clusterId) + // during the broadcast, modifying the Map during live iteration. + if (activeWorkers.has(clusterId)) { + context.subscribers.clear(); + } + } + + // Await all inflight polls concurrently now that all workers are stopped. + const pollResults = await Promise.allSettled(inflightPromises.map(([, p]) => p)); + pollResults.forEach((result, i) => { + if (result.status === 'rejected') { + logger.error( + { clusterId: inflightPromises[i][0], err: normalizeError(result.reason) }, + '[EventBus] Error awaiting poll' + ); + } + }); + activeWorkers.clear(); + // Reset metrics + activeWorkersGauge.set(0); + sseSubscribersGauge.reset(); +} diff --git a/src/lib/server/events/state.ts b/src/lib/server/events/state.ts new file mode 100644 index 00000000..00aee05b --- /dev/null +++ b/src/lib/server/events/state.ts @@ -0,0 +1,16 @@ +import type { ClusterContext } from './types.js'; + +export const SERVER_SESSION_ID = Date.now().toString(36); + +// Worker state is scoped by canonical cluster ID and discarded when the final subscriber leaves. +export const activeWorkers = new Map(); + +let shuttingDown = false; + +export function isEventBusShuttingDown(): boolean { + return shuttingDown; +} + +export function setEventBusShuttingDown(): void { + shuttingDown = true; +} diff --git a/src/lib/server/events/types.ts b/src/lib/server/events/types.ts new file mode 100644 index 00000000..a1e67c5c --- /dev/null +++ b/src/lib/server/events/types.ts @@ -0,0 +1,30 @@ +export function normalizeError(value: unknown): Error | { message: string; value: unknown } { + if (value instanceof Error) return value; + if (typeof value === 'string') return new Error(value); + return { message: 'Non-Error rejection', value }; +} + +export interface SSEEvent { + type: 'CONNECTED' | 'ADDED' | 'MODIFIED' | 'DELETED' | 'HEARTBEAT' | 'SHUTDOWN'; + clusterId?: string; + resourceType?: string; + resource?: unknown; + message?: string; + timestamp: string; + serverSessionId?: string; + reason?: string; +} + +export type Subscriber = (event: SSEEvent) => void; + +export interface ClusterContext { + clusterId: string; + subscribers: Set; + isActive: boolean; + pollTimeout: NodeJS.Timeout | null; + heartbeatInterval: NodeJS.Timeout | null; + inflightPollPromise: Promise | null; + lastStates: Map; + lastNotificationStates: Map; + resourceFirstSeen: Map; +} diff --git a/src/lib/server/flux/use-cases/batch-operation.ts b/src/lib/server/flux/use-cases/batch-operation.ts new file mode 100644 index 00000000..15e119a7 --- /dev/null +++ b/src/lib/server/flux/use-cases/batch-operation.ts @@ -0,0 +1,9 @@ +import { deleteFluxResourcesBatch, type DeleteItem } from '$lib/server/kubernetes/client.js'; + +export function batchDeleteUseCase(params: { + concurrency?: number; + items: DeleteItem[]; + locals: App.Locals; +}) { + return deleteFluxResourcesBatch(params.items, params.locals.cluster, params.concurrency); +} diff --git a/src/lib/server/flux/use-cases/create-resource.ts b/src/lib/server/flux/use-cases/create-resource.ts new file mode 100644 index 00000000..41b3597e --- /dev/null +++ b/src/lib/server/flux/use-cases/create-resource.ts @@ -0,0 +1,18 @@ +import { createFluxResource, type ReqCache } from '$lib/server/kubernetes/client.js'; +import { resolveFluxResourceType } from './resolve-resource-type.js'; + +export function createResourceUseCase(params: { + body: Record; + locals: App.Locals; + namespace: string; + reqCache?: ReqCache; + resourceType: string; +}) { + return createFluxResource( + resolveFluxResourceType(params.resourceType), + params.namespace, + params.body, + params.locals.cluster, + params.reqCache + ); +} diff --git a/src/lib/server/flux/use-cases/delete-resource.ts b/src/lib/server/flux/use-cases/delete-resource.ts new file mode 100644 index 00000000..fc63262a --- /dev/null +++ b/src/lib/server/flux/use-cases/delete-resource.ts @@ -0,0 +1,18 @@ +import { deleteFluxResource, type ReqCache } from '$lib/server/kubernetes/client.js'; +import { resolveFluxResourceType } from './resolve-resource-type.js'; + +export function deleteResourceUseCase(params: { + locals: App.Locals; + name: string; + namespace: string; + reqCache?: ReqCache; + resourceType: string; +}) { + return deleteFluxResource( + resolveFluxResourceType(params.resourceType), + params.namespace, + params.name, + params.locals.cluster, + params.reqCache + ); +} diff --git a/src/lib/server/flux/use-cases/diff-resource.ts b/src/lib/server/flux/use-cases/diff-resource.ts new file mode 100644 index 00000000..9698a748 --- /dev/null +++ b/src/lib/server/flux/use-cases/diff-resource.ts @@ -0,0 +1,15 @@ +import { resolveFluxResourceType } from './resolve-resource-type.js'; + +export interface DiffResourceUseCaseParams { + locals: App.Locals; + name: string; + namespace: string; + resourceType: string; +} + +export function normalizeDiffResourceParams(params: DiffResourceUseCaseParams) { + return { + ...params, + resourceType: resolveFluxResourceType(params.resourceType) + }; +} diff --git a/src/lib/server/flux/use-cases/history-resource.ts b/src/lib/server/flux/use-cases/history-resource.ts new file mode 100644 index 00000000..d2e0e2ff --- /dev/null +++ b/src/lib/server/flux/use-cases/history-resource.ts @@ -0,0 +1,16 @@ +import { getResourceHistory } from '$lib/server/kubernetes/flux/history.js'; +import { resolveFluxResourceType } from './resolve-resource-type.js'; + +export function historyResourceUseCase(params: { + locals: App.Locals; + name: string; + namespace: string; + resourceType: string; +}) { + return getResourceHistory( + resolveFluxResourceType(params.resourceType), + params.namespace, + params.name, + params.locals.cluster + ); +} diff --git a/src/lib/server/flux/use-cases/list-resource.ts b/src/lib/server/flux/use-cases/list-resource.ts new file mode 100644 index 00000000..d4767da0 --- /dev/null +++ b/src/lib/server/flux/use-cases/list-resource.ts @@ -0,0 +1,10 @@ +import { listFluxResourcesForType } from '../services.js'; +import type { ListOptions } from '$lib/server/kubernetes/client.js'; + +export function listResourceUseCase(params: { + locals: App.Locals; + query: ListOptions; + resourceType: string; +}) { + return listFluxResourcesForType(params); +} diff --git a/src/lib/server/flux/use-cases/resolve-resource-type.ts b/src/lib/server/flux/use-cases/resolve-resource-type.ts new file mode 100644 index 00000000..41fa4aa0 --- /dev/null +++ b/src/lib/server/flux/use-cases/resolve-resource-type.ts @@ -0,0 +1,14 @@ +import { + getResourceDef, + getResourceTypeByPlural, + type FluxResourceType +} from '$lib/server/kubernetes/flux/resources.js'; + +export function resolveFluxResourceType(resourceType: string): FluxResourceType { + const fromPlural = getResourceTypeByPlural(resourceType); + const candidate = (fromPlural ?? resourceType) as FluxResourceType; + if (!getResourceDef(candidate)) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + return candidate; +} diff --git a/src/lib/server/flux/use-cases/resource-events.ts b/src/lib/server/flux/use-cases/resource-events.ts new file mode 100644 index 00000000..ad76bd97 --- /dev/null +++ b/src/lib/server/flux/use-cases/resource-events.ts @@ -0,0 +1,16 @@ +import { getResourceEvents } from '$lib/server/kubernetes/events.js'; +import { resolveFluxResourceType } from './resolve-resource-type.js'; + +export function resourceEventsUseCase(params: { + locals: App.Locals; + name: string; + namespace: string; + resourceType: string; +}) { + return getResourceEvents( + params.namespace, + params.name, + resolveFluxResourceType(params.resourceType), + params.locals.cluster + ); +} diff --git a/src/lib/server/flux/use-cases/rollback-resource.ts b/src/lib/server/flux/use-cases/rollback-resource.ts new file mode 100644 index 00000000..20a28c6c --- /dev/null +++ b/src/lib/server/flux/use-cases/rollback-resource.ts @@ -0,0 +1,18 @@ +import { rollbackResource } from '$lib/server/kubernetes/flux/history.js'; +import { resolveFluxResourceType } from './resolve-resource-type.js'; + +export function rollbackResourceUseCase(params: { + locals: App.Locals; + name: string; + namespace: string; + resourceType: string; + revisionId: string; +}) { + return rollbackResource( + resolveFluxResourceType(params.resourceType), + params.namespace, + params.name, + params.revisionId, + params.locals.cluster + ); +} diff --git a/src/lib/server/flux/use-cases/update-resource.ts b/src/lib/server/flux/use-cases/update-resource.ts new file mode 100644 index 00000000..f28f1f4b --- /dev/null +++ b/src/lib/server/flux/use-cases/update-resource.ts @@ -0,0 +1,20 @@ +import { updateFluxResource, type ReqCache } from '$lib/server/kubernetes/client.js'; +import { resolveFluxResourceType } from './resolve-resource-type.js'; + +export function updateResourceUseCase(params: { + body: Record; + locals: App.Locals; + name: string; + namespace: string; + reqCache?: ReqCache; + resourceType: string; +}) { + return updateFluxResource( + resolveFluxResourceType(params.resourceType), + params.namespace, + params.name, + params.body, + params.locals.cluster, + params.reqCache + ); +} diff --git a/src/lib/server/initialize.ts b/src/lib/server/initialize.ts index eeceb575..0cd3a9fc 100644 --- a/src/lib/server/initialize.ts +++ b/src/lib/server/initialize.ts @@ -1,374 +1,2 @@ -import { logger } from './logger.js'; -import { getDbSync, closeDb } from './db/index.js'; -import { createDefaultAdminIfNeeded, setSetupTokenFile } from './auth.js'; -import { initDatabase } from './db/migrate.js'; -import { initializeDefaultPolicies, repairUserPolicyBindings } from './rbac-defaults.js'; -import { quarantineInvalidNamespacePatterns } from './rbac.js'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - testEncryption as testAuthEncryption, - isUsingDevelopmentKey as isUsingDevAuthKey -} from './auth/crypto.js'; -import { - testEncryption as testClusterEncryption, - isUsingDevelopmentKey as isUsingDevClusterKey, - migrateKubeconfigs -} from './clusters.js'; -import { seedAuthSettings } from './settings.js'; -import { seedAuthProviders } from './auth/seed-providers.js'; -import { scheduleCleanup, stopCleanup } from './kubernetes/flux/reconciliation-cleanup.js'; -import { scheduleSessionCleanup, stopSessionCleanup } from './auth/session-cleanup.js'; -import { scheduleAuditLogCleanup, stopAuditLogCleanup } from './audit.js'; -import { closeAllEventStreams, setEventBusShuttingDown } from './events.js'; -import { validateProductionSecurityConfig } from './security-config.js'; - -const IN_CLUSTER_NAMESPACE_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/namespace'; - -let isShuttingDown = false; -let activeShutdownPromise: Promise | null = null; - -function safeCloseDb(context: string = 'shutdown') { - try { - closeDb(); - logger.info(` ✓ Database connection closed (${context})`); - } catch (error) { - logger.error(error, ` ✗ Error closing database during ${context}:`); - } -} - -/** - * Shutdown Gyre gracefully, awaiting any in-flight cleanup work before exiting. - */ -export async function shutdownGyre(): Promise { - logger.info('\n🛑 Shutting down Gyre background tasks...'); - // Mark event bus as shutting down early to reject new subscriptions before await steps - setEventBusShuttingDown(); - try { - const results = await Promise.allSettled([stopCleanup(), stopAuditLogCleanup()]); - results.forEach((result, index) => { - if (result.status === 'rejected') { - const task = index === 0 ? 'stopCleanup' : 'stopAuditLogCleanup'; - logger.error(result.reason, ` ✗ Error during ${task}:`); - } - }); - try { - stopSessionCleanup(); - } catch (error) { - logger.error(error, ' ✗ Error during stopSessionCleanup:'); - } - await closeAllEventStreams(); - logger.info(' ✓ Cleanup schedulers and SSE connections stopped'); - } catch (error) { - logger.error(error, ' ✗ Error during shutdown:'); - } -} - -// Register shutdown handlers -if (typeof process !== 'undefined') { - let forceExit: NodeJS.Timeout | null = null; - let httpDrainTimeout: NodeJS.Timeout | null = null; - - const handleSignal = async (signal: string) => { - if (isShuttingDown) return; - isShuttingDown = true; - logger.info(`\n🛑 Received ${signal}, starting graceful shutdown...`); - - const isProd = process.env.NODE_ENV === 'production'; - - // Force-exit after 15s if graceful shutdown hangs (5s in dev) - // (K8s terminationGracePeriodSeconds defaults to 30s, so we want to exit before SIGKILL) - // NOTE: Lowering terminationGracePeriodSeconds below 30s in K8s manifests would break this timing. - forceExit = setTimeout( - () => { - logger.error(' ✗ Graceful shutdown took too long, forcing exit (HTTP drain timed out)'); - logger.error(' ✗ Force-exiting: any in-flight DB requests will fail'); - safeCloseDb('force-exit'); - process.exit(1); - }, - isProd ? 15_000 : 5_000 - ); - forceExit.unref(); - - activeShutdownPromise = shutdownGyre(); - await activeShutdownPromise; - - if (forceExit) { - clearTimeout(forceExit); - forceExit = null; - } - - // In development (vite), sveltekit:shutdown is not emitted. - // adapter-node only emits sveltekit:shutdown in production builds. - // We exit immediately after cleanup. - if (process.env.NODE_ENV !== 'production') { - safeCloseDb('development shutdown'); - process.exit(0); - } else { - // Production path: adapter-node will emit sveltekit:shutdown when HTTP drain completes. - // Guard against the event never firing. - httpDrainTimeout = setTimeout(() => { - logger.error(' ✗ sveltekit:shutdown not received within 10s, forcing exit'); - safeCloseDb('drain timeout'); - process.exit(1); - }, 10_000); - httpDrainTimeout.unref(); - } - }; - - process.on('SIGTERM', () => - handleSignal('SIGTERM').catch((err) => logger.error(err, 'Signal handler error:')) - ); - process.on('SIGINT', () => - handleSignal('SIGINT').catch((err) => logger.error(err, 'Signal handler error:')) - ); - - process.on('sveltekit:shutdown', async () => { - logger.info(' ✓ HTTP server stopped'); - - // If sveltekit:shutdown fires without prior signal (adapter handled it), - // run shutdown now to ensure cleanup completes. - // If a signal handler already started shutdown, await the same promise so - // safeCloseDb only runs after that work is fully done (no race). - if (!isShuttingDown) { - isShuttingDown = true; - // Arm the same fail-safe backstop used in handleSignal so a hung - // shutdownGyre() cannot block the process indefinitely. - const isProd = process.env.NODE_ENV === 'production'; - const svkForceExit = setTimeout( - () => { - logger.error(' ✗ Graceful shutdown took too long, forcing exit'); - safeCloseDb('force-exit'); - process.exit(1); - }, - isProd ? 15_000 : 5_000 - ); - svkForceExit.unref(); - activeShutdownPromise = shutdownGyre(); - await activeShutdownPromise; - clearTimeout(svkForceExit); - } else if (activeShutdownPromise) { - await activeShutdownPromise; - } - - // Clear fail-safe timers only after active shutdown finishes so they - // remain in place as a backstop if shutdownGyre() hangs. - if (forceExit) clearTimeout(forceExit); - if (httpDrainTimeout) clearTimeout(httpDrainTimeout); - - safeCloseDb('graceful shutdown'); - logger.info('👋 Gyre shutdown complete.'); - process.exit(0); - }); -} - -/** - * Get current namespace from in-cluster ServiceAccount - */ -function getCurrentNamespace(): string { - try { - return readFileSync(IN_CLUSTER_NAMESPACE_PATH, 'utf-8').trim(); - } catch { - return 'default'; - } -} - -/** - * Initialize Gyre on startup - * - Creates database tables if they don't exist - * - Creates default admin user if no users exist - * - Logs deployment mode - */ -export async function initializeGyre(): Promise { - logger.info('='.repeat(60)); - logger.info(' Gyre - FluxCD Dashboard'); - logger.info('='.repeat(60)); - - // Log deployment mode - const isInCluster = !!process.env.KUBERNETES_SERVICE_HOST; - const isProd = process.env.NODE_ENV === 'production'; - - if (isInCluster) { - logger.info('📦 Deployment Mode: In-Cluster'); - logger.info(' Using Kubernetes ServiceAccount for API access'); - } else { - logger.info('📦 Deployment Mode: Local Development'); - logger.info(' Using local kubeconfig for cluster access'); - } - - // Encryption checks - logger.info('\n🔐 Validating encryption...'); - try { - // Test Auth Encryption - if (!testAuthEncryption()) { - throw new Error('Authentication encryption test failed!'); - } - if (isUsingDevAuthKey()) { - if (isProd) { - throw new Error('AUTH_ENCRYPTION_KEY must be set in production!'); - } - logger.warn(' ⚠️ Using development key for AUTH_ENCRYPTION_KEY'); - } - - // Test Cluster Encryption - if (!testClusterEncryption()) { - throw new Error('Cluster kubeconfig encryption test failed!'); - } - if (isUsingDevClusterKey()) { - if (isProd) { - throw new Error('GYRE_ENCRYPTION_KEY must be set in production!'); - } - logger.warn(' ⚠️ Using development key for GYRE_ENCRYPTION_KEY'); - } - - logger.info(' ✓ Encryption validation passed'); - } catch (error) { - logger.error(error, ' ✗ Encryption validation failed'); - throw error; - } - - if (isProd) { - logger.info('\n🛡️ Validating production security configuration...'); - try { - validateProductionSecurityConfig(); - logger.info(' ✓ Production security configuration validated'); - } catch (error) { - logger.error(error, ' ✗ Production security configuration invalid'); - throw error; - } - } - - // Initialize database connection and tables - logger.info('\n🗄️ Initializing database...'); - try { - getDbSync(); - initDatabase(); // Create tables if they don't exist - logger.info(' ✓ Database connection established'); - } catch (error) { - logger.error(error, ' ✗ Failed to connect to database'); - throw error; - } - - // Migrate kubeconfigs to new encryption format if needed (must run after DB is initialized) - try { - const { migrated, failed } = await migrateKubeconfigs(); - if (migrated > 0) { - logger.info(` ✓ Migrated ${migrated} cluster(s) to new encryption format`); - } - if (failed > 0) { - logger.warn(` ⚠️ Failed to migrate ${failed} cluster(s) — check logs above for details`); - } - } catch (error) { - logger.error(error, ' ✗ Failed to migrate kubeconfigs'); - // Don't throw here, as the app can still function with old encryption if migration fails - } - - // Create default admin if needed - logger.info('\n👤 Setting up authentication...'); - try { - const { password: setupToken, mode } = await createDefaultAdminIfNeeded(); - - if (setupToken) { - logger.info(' ⚠️ FIRST TIME SETUP - INITIAL ADMIN PASSWORD:'); - logger.info(' ' + '='.repeat(50)); - logger.info(' Username: admin'); - - if (mode === 'in-cluster') { - // In-cluster mode: show K8s secret command - const namespace = getCurrentNamespace(); - logger.info(' Password has been securely stored in a Kubernetes secret.'); - logger.info(' ' + '='.repeat(50)); - logger.info(' \n 📋 To retrieve the password, run:'); - logger.info( - ` kubectl get secret gyre-initial-admin-secret -n ${namespace} -o jsonpath='{.data.password}' | base64 -d` - ); - logger.info('\n ⚠️ Please change this password after first login!'); - logger.info(' After first login, the secret will be marked as consumed.'); - } else { - // Local development mode: write token to a restricted temp file to avoid - // plaintext credentials appearing in container or terminal logs. - const tokenFile = join(tmpdir(), `gyre-setup-token-${Date.now()}.txt`); - try { - writeFileSync(tokenFile, setupToken, { mode: 0o600, flag: 'wx' }); - } catch (writeErr) { - logger.error(writeErr, ' ✗ Failed to write setup token file'); - throw writeErr; - } - // Register the file path so auth.ts can remove it after first login. - setSetupTokenFile(tokenFile); - logger.warn(' ⚠️ WARNING: Container or terminal logs may capture plaintext passwords.'); - logger.warn(' The setup token has been written to a restricted file (mode 0600).'); - logger.info(` Token file: ${tokenFile}`); - logger.info(' ' + '='.repeat(50)); - logger.info('\n 💡 For local development, you can also set ADMIN_PASSWORD env var'); - logger.info(" ⚠️ Please read the token from the file above - it won't be shown again!"); - } - } - logger.info(' ✓ Authentication ready'); - } catch (error) { - logger.error(error, ' ✗ Failed to setup authentication'); - throw error; - } - - // Initialize default RBAC policies - logger.info('\n🔐 Setting up RBAC policies...'); - try { - await initializeDefaultPolicies(); - const repairedCount = await repairUserPolicyBindings(); - if (repairedCount > 0) { - logger.info(` ✓ Repaired RBAC bindings for ${repairedCount} existing user(s)`); - } - const quarantinedCount = await quarantineInvalidNamespacePatterns(); - if (quarantinedCount > 0) { - logger.warn( - ` ⚠ Quarantined ${quarantinedCount} RBAC policy/policies with invalid namespacePattern` - ); - } - logger.info(' ✓ RBAC policies ready'); - } catch (error) { - logger.error(error, ' ✗ Failed to setup RBAC policies'); - throw error; - } - - // Seed auth settings and providers from environment - logger.info('\n🔑 Setting up authentication settings...'); - try { - await seedAuthSettings(); - const seedResult = await seedAuthProviders(); - if (seedResult.created > 0) { - logger.info(` ✓ Seeded ${seedResult.created} auth provider(s)`); - } - if (seedResult.skipped > 0) { - logger.info( - ` ℹ Skipped ${seedResult.skipped} provider(s) (existing or invalid/missing secrets)` - ); - } - logger.info(' ✓ Authentication settings ready'); - } catch (error) { - logger.error(error, ' ✗ Failed to seed auth settings'); - // Don't throw - app can still work without seeded providers - } - - // Schedule reconciliation history cleanup - logger.info('\n🧹 Setting up data cleanup...'); - try { - scheduleCleanup(); - scheduleAuditLogCleanup(); - logger.info(' ✓ Cleanup schedulers initialized'); - } catch (error) { - logger.error(error, ' ✗ Failed to schedule cleanup'); - // Don't throw - app can still work without cleanup - } - - logger.info('\n' + '='.repeat(60)); - logger.info(' Gyre is ready!'); - logger.info('='.repeat(60) + '\n'); -} - -// Initialize session cleanup immediately at startup -try { - scheduleSessionCleanup(); -} catch (error) { - logger.error(error, '[SessionCleanup] Failed to initialize scheduler'); -} +export { initializeGyre } from './lifecycle/startup.js'; +export { shutdownGyre } from './lifecycle/shutdown.js'; diff --git a/src/lib/server/kubernetes/client-factory.ts b/src/lib/server/kubernetes/client-factory.ts new file mode 100644 index 00000000..52c1f6ba --- /dev/null +++ b/src/lib/server/kubernetes/client-factory.ts @@ -0,0 +1,78 @@ +import { logger } from '../logger.js'; +import * as k8s from '@kubernetes/client-node'; +import * as http from 'http'; +import * as https from 'https'; +import { _createTimeoutMiddleware } from './timeouts.js'; + +// --------------------------------------------------------------------------- +// HTTP Agent configuration (Keep-Alive support) +// --------------------------------------------------------------------------- + +/** + * HTTP agent with keep-alive enabled for efficient connection reuse. + * Configuration: + * - keepAlive: true — Reuse TCP connections across requests + * - keepAliveMsecs: 30000 — TCP keep-alive probe every 30s + * - maxSockets: 100 — Limit concurrent connections per agent + * - maxFreeSockets: 20 — Keep up to 20 idle sockets open + * - timeout: 30000 — Socket timeout + */ +const httpAgent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 30_000, + maxSockets: 100, + maxFreeSockets: 20, + timeout: 30_000 +}); + +/** + * HTTPS agent with keep-alive enabled. + * Configuration matches HTTP agent for consistency. + */ +const httpsAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30_000, + maxSockets: 100, + maxFreeSockets: 20, + timeout: 30_000 +}); + +// Note: HTTP_PROXY/HTTPS_PROXY environment variables are respected at the Node.js +// level for global HTTP agent behavior. For explicit proxy agent support (e.g., with +// HttpProxyAgent/HttpsProxyAgent packages), implement in a future enhancement. + +/** Creates an API client with an AbortController-based timeout middleware and HTTP keep-alive. */ +export function makeApiClientWithTimeout( + kubeConfig: k8s.KubeConfig, + apiClientType: k8s.ApiConstructor, + timeoutMs: number +): T { + const cluster = kubeConfig.getCurrentCluster(); + if (!cluster) throw new Error('No active cluster!'); + const baseServerConfig = new k8s.ServerConfiguration(cluster.server, {}); + const agent = cluster.server.startsWith('https:') ? httpsAgent : httpAgent; + + const config = k8s.createConfiguration({ + baseServer: baseServerConfig, + authMethods: { default: kubeConfig }, + promiseMiddleware: [ + { + pre: async (ctx: k8s.RequestContext) => { + ctx.setAgent(agent); + return _createTimeoutMiddleware(timeoutMs).pre(ctx); + }, + post: async (ctx: k8s.ResponseContext) => ctx + } + ] + }); + return new apiClientType(config); +} + +export function destroyHttpAgents(): void { + httpAgent.destroy(); + httpsAgent.destroy(); +} + +export function logKubernetesShutdownComplete(): void { + logger.info('✓ Kubernetes client gracefully shutdown'); +} diff --git a/src/lib/server/kubernetes/client-pool.ts b/src/lib/server/kubernetes/client-pool.ts new file mode 100644 index 00000000..c6030eb9 --- /dev/null +++ b/src/lib/server/kubernetes/client-pool.ts @@ -0,0 +1,227 @@ +import { logger } from '../logger.js'; +import { normalizeClusterId } from '$lib/clusters/identity.js'; +import * as k8s from '@kubernetes/client-node'; +import { OPERATION_TIMEOUTS } from './timeouts.js'; +import { + destroyHttpAgents, + logKubernetesShutdownComplete, + makeApiClientWithTimeout +} from './client-factory.js'; +import { clearBaseKubeConfig, getKubeConfig, type ReqCache } from './kubeconfig-provider.js'; + +// Connection pool cache scope: process-local, TTL/LRU, explicit clear via admin endpoint. +// --------------------------------------------------------------------------- +// Connection pool — singleton API clients per canonical cluster ID +// --------------------------------------------------------------------------- + +const POOL_TTL_MS = 5 * 60 * 1000; // 5 minutes +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes +/** Maximum pooled entries per API type; oldest-accessed entry is evicted when exceeded. */ +const MAX_POOL_SIZE = 50; + +interface PoolEntry { + client: T; + createdAt: number; + lastAccess: number; +} + +const customObjectsPool = new Map>(); +const coreV1Pool = new Map>(); +const appsV1Pool = new Map>(); + +const poolMetricsState = { hits: 0, misses: 0, evictions: 0 }; + +/** Removes the bottom 20% least-recently-used entries from a pool (min 1). */ +function evictLRU(pool: Map>) { + const evictCount = Math.max(1, Math.ceil(pool.size * 0.2)); + const sorted = [...pool.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess); + for (const [k] of sorted.slice(0, evictCount)) { + pool.delete(k); + poolMetricsState.evictions++; + } +} + +/** Proactively removes expired entries from all pools. */ +function pruneExpiredEntries() { + const now = Date.now(); + for (const pool of [customObjectsPool, coreV1Pool, appsV1Pool] as Map< + string, + PoolEntry + >[]) { + for (const [key, entry] of pool) { + if (now - entry.createdAt >= POOL_TTL_MS) { + pool.delete(key); + poolMetricsState.evictions++; + } + } + } +} + +// Periodic cleanup — runs every 10 min so expired entries (5 min TTL) may linger +// up to 10 min before proactive removal; on-access eviction in getOrCreate catches +// them sooner. .unref() prevents the timer from blocking process exit. +const cleanupTimer = setInterval(() => { + try { + pruneExpiredEntries(); + } catch (e) { + logger.error(e, 'K8s client pool cleanup error'); + } +}, CLEANUP_INTERVAL_MS); +if (typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) { + (cleanupTimer as NodeJS.Timeout).unref(); +} + +/** Returns current connection pool metrics (hits, misses, evictions, pool sizes). */ +export function getPoolMetrics() { + return { + hits: poolMetricsState.hits, + misses: poolMetricsState.misses, + evictions: poolMetricsState.evictions, + poolSizes: { + customObjects: customObjectsPool.size, + coreV1: coreV1Pool.size, + appsV1: appsV1Pool.size + } + }; +} + +function clearPoolByPrefix(pool: Map>, prefix: string) { + for (const key of pool.keys()) { + if (key.startsWith(prefix)) { + pool.delete(key); + poolMetricsState.evictions++; + } + } +} + +/** Evicts pooled clients. Exposed via POST /api/v1/admin/k8s/clear-client-pool. */ +export function clearClientPool(clusterId?: string) { + if (clusterId === undefined) { + customObjectsPool.clear(); + coreV1Pool.clear(); + appsV1Pool.clear(); + poolMetricsState.hits = 0; + poolMetricsState.misses = 0; + poolMetricsState.evictions = 0; + return; + } + + const prefix = `${normalizeClusterId(clusterId)}:`; + clearPoolByPrefix(customObjectsPool, prefix); + clearPoolByPrefix(coreV1Pool, prefix); + clearPoolByPrefix(appsV1Pool, prefix); +} + +/** + * Gracefully shutdown the Kubernetes client by closing all connections and cleanup resources. + * Clears all pooled clients, closes HTTP agents, and stops the cleanup interval. + * Safe to call multiple times. + */ +export function gracefulShutdown(): void { + try { + // Clear all pools (evicts all clients) + clearClientPool(); + + // Close global HTTP/HTTPS agents and their sockets + destroyHttpAgents(); + clearBaseKubeConfig(); + + // Clear the periodic cleanup timer + if (typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) { + clearInterval(cleanupTimer as NodeJS.Timeout); + } + + logKubernetesShutdownComplete(); + } catch (e) { + logger.error(e, 'Error during graceful shutdown of Kubernetes client'); + } +} + +async function getOrCreate( + pool: Map>, + key: string, + factory: () => Promise +): Promise { + const now = Date.now(); + const entry = pool.get(key); + + if (entry) { + const isExpired = now - entry.createdAt >= POOL_TTL_MS; + + if (isExpired) { + // TTL expired — evict stale entry + pool.delete(key); + poolMetricsState.evictions++; + } else { + // Connection is valid + entry.lastAccess = now; + poolMetricsState.hits++; + return entry.client; + } + } + + if (pool.size >= MAX_POOL_SIZE) { + // Pool full — prefer evicting expired entries first to avoid discarding + // still-valid connections; fall back to LRU only if none are expired. + pruneExpiredEntries(); + if (pool.size >= MAX_POOL_SIZE) { + evictLRU(pool); + } + } + + poolMetricsState.misses++; + const client = await factory(); + pool.set(key, { client, createdAt: now, lastAccess: now }); + return client; +} + +/** + * Create CustomObjectsApi client, reusing a pooled instance when possible. + * getKubeConfig is invoked inside the factory so it is only called on cache misses. + * @param context - Optional canonical cluster ID + */ +export async function getCustomObjectsApi( + context?: string, + reqCache?: ReqCache, + timeoutMs = OPERATION_TIMEOUTS.list +): Promise { + const key = normalizeClusterId(context); + return getOrCreate(customObjectsPool, `${key}:${timeoutMs}`, async () => { + const config = await getKubeConfig(key, reqCache); + return makeApiClientWithTimeout(config, k8s.CustomObjectsApi, timeoutMs); + }); +} + +/** + * Create CoreV1Api client, reusing a pooled instance when possible. + * getKubeConfig is invoked inside the factory so it is only called on cache misses. + * @param context - Optional canonical cluster ID + */ +export async function getCoreV1Api( + context?: string, + reqCache?: ReqCache, + timeoutMs = OPERATION_TIMEOUTS.get +): Promise { + const key = normalizeClusterId(context); + return getOrCreate(coreV1Pool, `${key}:${timeoutMs}`, async () => { + const config = await getKubeConfig(key, reqCache); + return makeApiClientWithTimeout(config, k8s.CoreV1Api, timeoutMs); + }); +} + +/** + * Create AppsV1Api client, reusing a pooled instance when possible. + * getKubeConfig is invoked inside the factory so it is only called on cache misses. + * @param context - Optional canonical cluster ID + */ +export async function getAppsV1Api( + context?: string, + reqCache?: ReqCache, + timeoutMs = OPERATION_TIMEOUTS.get +): Promise { + const key = normalizeClusterId(context); + return getOrCreate(appsV1Pool, `${key}:${timeoutMs}`, async () => { + const config = await getKubeConfig(key, reqCache); + return makeApiClientWithTimeout(config, k8s.AppsV1Api, timeoutMs); + }); +} diff --git a/src/lib/server/kubernetes/client.ts b/src/lib/server/kubernetes/client.ts index c6da4bf4..78d76b25 100644 --- a/src/lib/server/kubernetes/client.ts +++ b/src/lib/server/kubernetes/client.ts @@ -1,1120 +1,9 @@ -import { logger } from '../logger.js'; -import { IN_CLUSTER_ID, normalizeClusterId } from '$lib/clusters/identity.js'; -import * as k8s from '@kubernetes/client-node'; -import * as http from 'http'; -import * as https from 'https'; -import { loadKubeConfig } from './config.js'; -import { getClusterKubeconfig } from '../clusters.js'; -import { - getResourceDef, - getResourceTypeByPlural, - type FluxResourceType -} from './flux/resources.js'; -import type { FluxResource, FluxResourceList } from './flux/types.js'; -import { - KubernetesError, - KubernetesTimeoutError, - ResourceNotFoundError, - AuthenticationError, - AuthorizationError, - ClusterUnavailableError -} from './errors.js'; - -// --------------------------------------------------------------------------- -// HTTP Agent configuration (Keep-Alive support) -// --------------------------------------------------------------------------- - -/** - * HTTP agent with keep-alive enabled for efficient connection reuse. - * Configuration: - * - keepAlive: true — Reuse TCP connections across requests - * - keepAliveMsecs: 30000 — TCP keep-alive probe every 30s - * - maxSockets: 100 — Limit concurrent connections per agent - * - maxFreeSockets: 20 — Keep up to 20 idle sockets open - * - timeout: 30000 — Socket timeout - */ -const httpAgent = new http.Agent({ - keepAlive: true, - keepAliveMsecs: 30_000, - maxSockets: 100, - maxFreeSockets: 20, - timeout: 30_000 -}); - -/** - * HTTPS agent with keep-alive enabled. - * Configuration matches HTTP agent for consistency. - */ -const httpsAgent = new https.Agent({ - keepAlive: true, - keepAliveMsecs: 30_000, - maxSockets: 100, - maxFreeSockets: 20, - timeout: 30_000 -}); - -// Note: HTTP_PROXY/HTTPS_PROXY environment variables are respected at the Node.js -// level for global HTTP agent behavior. For explicit proxy agent support (e.g., with -// HttpProxyAgent/HttpsProxyAgent packages), implement in a future enhancement. - -// --------------------------------------------------------------------------- -// Timeout configuration -// --------------------------------------------------------------------------- - -/** Default timeout for all Kubernetes API requests (30 seconds). */ -export const DEFAULT_TIMEOUT_MS = 30_000; - -/** - * Per-operation timeout overrides (ms). Falls back to DEFAULT_TIMEOUT_MS. - * "logs" is higher because log fetching can be slow on large pods. - * "delete" has same timeout as create/update operations. - */ -export const OPERATION_TIMEOUTS: Record = { - list: 30_000, - get: 15_000, - create: 30_000, - update: 30_000, - delete: 30_000, - logs: 60_000 -}; - -/** Returns a PromiseMiddleware that aborts requests exceeding `timeoutMs`. Exported for testing. */ -export function _createTimeoutMiddleware(timeoutMs: number): k8s.Middleware { - return { - pre: async (ctx: k8s.RequestContext) => { - const controller = new AbortController(); - const existingSignal = ctx.getSignal(); - - // Declared before timer so the closure captures the binding correctly. - let timer: ReturnType; - - // If a caller passed an upstream signal (e.g. a request-level AbortController), - // propagate its cancellation: clear our timer and abort our controller. - const onUpstreamAbort = () => { - clearTimeout(timer); - controller.abort(); - }; - if (existingSignal) { - existingSignal.addEventListener('abort', onUpstreamAbort, { once: true }); - } - - timer = setTimeout(() => { - // Timeout fired — remove the upstream listener so it cannot fire later. - if (existingSignal) { - existingSignal.removeEventListener('abort', onUpstreamAbort); - } - controller.abort(); - }, timeoutMs); - - ctx.setSignal(controller.signal); - return ctx; - }, - // ResponseContext does not expose the request signal, so timer cleanup on - // successful completion is handled by the setTimeout firing as a no-op once - // the fetch promise has already settled. - post: async (ctx: k8s.ResponseContext) => ctx - }; -} - -/** Creates an API client with an AbortController-based timeout middleware and HTTP keep-alive. */ -function makeApiClientWithTimeout( - kubeConfig: k8s.KubeConfig, - apiClientType: k8s.ApiConstructor, - timeoutMs: number -): T { - const cluster = kubeConfig.getCurrentCluster(); - if (!cluster) throw new Error('No active cluster!'); - const baseServerConfig = new k8s.ServerConfiguration(cluster.server, {}); - - // Note: HTTP agents are configured globally above (httpAgent/httpsAgent singletons) - // for maximum connection reuse. The kubernetes client-node library respects - // global agent configuration at the Node.js level. - const config = k8s.createConfiguration({ - baseServer: baseServerConfig, - authMethods: { default: kubeConfig }, - promiseMiddleware: [_createTimeoutMiddleware(timeoutMs)] - }); - return new apiClientType(config); -} - -export interface ListOptions { - limit?: number; - offset?: number; - sortBy?: 'name' | 'age' | 'status'; - sortOrder?: 'asc' | 'desc'; -} - -export interface PaginatedFluxResourceList { - items: FluxResource[]; - /** Exact total, or null when cursor-based native paging was used and the total is unknown. */ - total: number | null; - hasMore: boolean; - offset: number; - limit: number; - metadata: { - resourceVersion?: string; - /** k8s continue token; present only when native k8s paging was used. */ - continueToken?: string; - }; -} - -const STATUS_ORDER: Record = { - failed: 0, - progressing: 1, - suspended: 2, - unknown: 3, - healthy: 4 -}; - -function getResourceStatus(resource: FluxResource): string { - if (resource.spec?.suspend) return 'suspended'; - const conditions = resource.status?.conditions; - if (!conditions || conditions.length === 0) return 'unknown'; - const stalled = conditions.find((c) => c.type === 'Stalled' || c.type === 'Failed'); - if (stalled?.status === 'True') return 'failed'; - const gen = resource.metadata.generation; - const obsGen = resource.status?.observedGeneration; - if (gen !== undefined && obsGen !== undefined && obsGen < gen) return 'progressing'; - for (const type of ['Ready', 'Healthy', 'Succeeded', 'Available']) { - const cond = conditions.find((c) => c.type === type); - if (cond) { - if (cond.status === 'True') return 'healthy'; - if ( - cond.status === 'False' && - (cond.reason === 'Progressing' || - cond.reason === 'ProgressingWithRetry' || - cond.reason === 'DependencyNotReady' || - cond.reason === 'ReconciliationInProgress') - ) { - return 'progressing'; - } - if (cond.status === 'False') return 'failed'; - } - } - return 'unknown'; -} - -function sortResources( - items: FluxResource[], - sortBy: ListOptions['sortBy'], - sortOrder: ListOptions['sortOrder'] = 'asc' -): FluxResource[] { - const sorted = [...items].sort((a, b) => { - let cmp = 0; - if (sortBy === 'name') { - cmp = (a.metadata.name ?? '').localeCompare(b.metadata.name ?? ''); - } else if (sortBy === 'age') { - const aTime = a.metadata.creationTimestamp - ? new Date(a.metadata.creationTimestamp).getTime() - : 0; - const bTime = b.metadata.creationTimestamp - ? new Date(b.metadata.creationTimestamp).getTime() - : 0; - cmp = aTime - bTime; - } else if (sortBy === 'status') { - const aOrder = STATUS_ORDER[getResourceStatus(a)] ?? 3; - const bOrder = STATUS_ORDER[getResourceStatus(b)] ?? 3; - cmp = aOrder - bOrder; - } - // Deterministic tie-breaker: compare uid, fall back to name. - // Applied before direction inversion so sortOrder is respected consistently. - if (cmp === 0) { - cmp = (a.metadata.uid ?? a.metadata.name ?? '').localeCompare( - b.metadata.uid ?? b.metadata.name ?? '' - ); - } - return sortOrder === 'desc' ? -cmp : cmp; - }); - return sorted; -} - -// Store the base default config separately to avoid reloading it constantly -let baseConfig: k8s.KubeConfig | null = null; - -export type ReqCache = Map>; - -// --------------------------------------------------------------------------- -// Connection pool — singleton API clients per canonical cluster ID -// --------------------------------------------------------------------------- - -const POOL_TTL_MS = 5 * 60 * 1000; // 5 minutes -const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes -/** Maximum pooled entries per API type; oldest-accessed entry is evicted when exceeded. */ -const MAX_POOL_SIZE = 50; - -interface PoolEntry { - client: T; - createdAt: number; - lastAccess: number; -} - -const customObjectsPool = new Map>(); -const coreV1Pool = new Map>(); -const appsV1Pool = new Map>(); - -const poolMetricsState = { hits: 0, misses: 0, evictions: 0 }; - -/** Removes the bottom 20% least-recently-used entries from a pool (min 1). */ -function evictLRU(pool: Map>) { - const evictCount = Math.max(1, Math.ceil(pool.size * 0.2)); - const sorted = [...pool.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess); - for (const [k] of sorted.slice(0, evictCount)) { - pool.delete(k); - poolMetricsState.evictions++; - } -} - -/** Proactively removes expired entries from all pools. */ -function pruneExpiredEntries() { - const now = Date.now(); - for (const pool of [customObjectsPool, coreV1Pool, appsV1Pool] as Map< - string, - PoolEntry - >[]) { - for (const [key, entry] of pool) { - if (now - entry.createdAt >= POOL_TTL_MS) { - pool.delete(key); - poolMetricsState.evictions++; - } - } - } -} - -// Periodic cleanup — runs every 10 min so expired entries (5 min TTL) may linger -// up to 10 min before proactive removal; on-access eviction in getOrCreate catches -// them sooner. .unref() prevents the timer from blocking process exit. -const cleanupTimer = setInterval(() => { - try { - pruneExpiredEntries(); - } catch (e) { - logger.error(e, 'K8s client pool cleanup error'); - } -}, CLEANUP_INTERVAL_MS); -if (typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) { - (cleanupTimer as NodeJS.Timeout).unref(); -} - -/** Returns current connection pool metrics (hits, misses, evictions, pool sizes). */ -export function getPoolMetrics() { - return { - hits: poolMetricsState.hits, - misses: poolMetricsState.misses, - evictions: poolMetricsState.evictions, - poolSizes: { - customObjects: customObjectsPool.size, - coreV1: coreV1Pool.size, - appsV1: appsV1Pool.size - } - }; -} - -function clearPoolByPrefix(pool: Map>, prefix: string) { - for (const key of pool.keys()) { - if (key.startsWith(prefix)) { - pool.delete(key); - poolMetricsState.evictions++; - } - } -} - -/** Evicts pooled clients. Exposed via POST /api/v1/admin/k8s/clear-client-pool. */ -export function clearClientPool(clusterId?: string) { - if (clusterId === undefined) { - customObjectsPool.clear(); - coreV1Pool.clear(); - appsV1Pool.clear(); - poolMetricsState.hits = 0; - poolMetricsState.misses = 0; - poolMetricsState.evictions = 0; - return; - } - - const prefix = `${normalizeClusterId(clusterId)}:`; - clearPoolByPrefix(customObjectsPool, prefix); - clearPoolByPrefix(coreV1Pool, prefix); - clearPoolByPrefix(appsV1Pool, prefix); -} - -/** - * Gracefully shutdown the Kubernetes client by closing all connections and cleanup resources. - * Clears all pooled clients, closes HTTP agents, and stops the cleanup interval. - * Safe to call multiple times. - */ -export function gracefulShutdown(): void { - try { - // Clear all pools (evicts all clients) - clearClientPool(); - - // Close global HTTP/HTTPS agents and their sockets - httpAgent.destroy(); - httpsAgent.destroy(); - - // Clear the periodic cleanup timer - if (typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) { - clearInterval(cleanupTimer as NodeJS.Timeout); - } - - logger.info('✓ Kubernetes client gracefully shutdown'); - } catch (e) { - logger.error(e, 'Error during graceful shutdown of Kubernetes client'); - } -} - -/** - * Audit log helper for sensitive resource access (e.g., Secrets). - * Used to track compliance requirements for access to sensitive data. - * @param operation - Operation type (get, list, create, update, delete, patch) - * @param resourceType - Resource type (e.g., 'Secret', 'ConfigMap') - * @param namespace - Namespace of the resource - * @param name - Name of the resource (optional for list operations) - * @param context - Cluster context for multi-cluster setups - * - * @example - * // Log access to a Secret - * auditLogSecretAccess('get', 'Secret', 'default', 'my-secret', 'production'); - * // Output: [AUDIT] GET Secret default/my-secret (context: production) at 2024-03-24T... - */ -export function auditLogSecretAccess( - operation: 'get' | 'list' | 'create' | 'update' | 'delete' | 'patch', - resourceType: string, - namespace: string, - name?: string, - context?: string -): void { - const timestamp = new Date().toISOString(); - const resourceId = name ? `${namespace}/${name}` : namespace; - const msg = `[AUDIT] ${operation.toUpperCase()} ${resourceType} ${resourceId} (context: ${normalizeClusterId(context)}) at ${timestamp}`; - - // Use warn level for sensitive resource access to ensure it's logged to files - logger.warn(msg); -} - -/** - * Read a Kubernetes Secret (CoreV1 API) with audit logging. - * @param namespace - Namespace containing the Secret - * @param name - Name of the Secret - * @param context - Optional cluster context - */ -export async function readSecret( - namespace: string, - name: string, - context?: string -): Promise { - // Audit log before attempting read (logs both success and failure) - auditLogSecretAccess('get', 'Secret', namespace, name, context); - try { - const api = await getCoreV1Api(context); - const response = await api.readNamespacedSecret({ namespace, name }); - return response; - } catch (error) { - throw handleK8sError(error, `read Secret ${namespace}/${name}`); - } -} - -async function getOrCreate( - pool: Map>, - key: string, - factory: () => Promise -): Promise { - const now = Date.now(); - const entry = pool.get(key); - - if (entry) { - const isExpired = now - entry.createdAt >= POOL_TTL_MS; - - if (isExpired) { - // TTL expired — evict stale entry - pool.delete(key); - poolMetricsState.evictions++; - } else { - // Connection is valid - entry.lastAccess = now; - poolMetricsState.hits++; - return entry.client; - } - } - - if (pool.size >= MAX_POOL_SIZE) { - // Pool full — prefer evicting expired entries first to avoid discarding - // still-valid connections; fall back to LRU only if none are expired. - pruneExpiredEntries(); - if (pool.size >= MAX_POOL_SIZE) { - evictLRU(pool); - } - } - - poolMetricsState.misses++; - const client = await factory(); - pool.set(key, { client, createdAt: now, lastAccess: now }); - return client; -} - -/** - * Get or create KubeConfig for a specific canonical cluster ID. - * Only caches successful configs; failed promises are not cached to allow retries. - * @param clusterId - Optional cluster ID. undefined/default/in-cluster select the runtime config. - */ -export async function getKubeConfig( - clusterId?: string, - reqCache?: ReqCache -): Promise { - const key = normalizeClusterId(clusterId); - - // Check cache for successful configs only - if (reqCache && reqCache.has(key)) { - const cachedPromise = reqCache.get(key)!; - // Verify the cached promise hasn't rejected - // Note: We return the promise as-is; if it rejected, caller will handle the rejection - return cachedPromise; - } - - const loadConfig = async () => { - let config: k8s.KubeConfig; - - if (key === IN_CLUSTER_ID) { - if (!baseConfig) { - baseConfig = loadKubeConfig(); - } - config = new k8s.KubeConfig(); - config.loadFromString(baseConfig.exportConfig()); - } else { - const kubeconfigYaml = await getClusterKubeconfig(key); - if (!kubeconfigYaml) { - throw new Error(`Cluster with ID "${key}" not found or has no valid configuration`); - } - config = new k8s.KubeConfig(); - config.loadFromString(kubeconfigYaml); - logger.debug(`✓ Loaded Kubernetes configuration from database for cluster: ${key}`); - } - - return config; - }; - - // Wrap promise handling to implement success-only caching with concurrent deduplication - if (reqCache) { - // Create the promise - const cachedPromise = loadConfig() - .then((config) => { - // On success, cache the resolved promise for future calls - reqCache.set(key, Promise.resolve(config)); - return config; - }) - .catch((error) => { - // On failure, ensure no stale cache entry exists, allowing retries - reqCache.delete(key); - throw error; - }); - - // Store the in-flight promise immediately so concurrent callers reuse it - reqCache.set(key, cachedPromise); - - return cachedPromise; - } - - return loadConfig(); -} - -/** - * Create CustomObjectsApi client, reusing a pooled instance when possible. - * getKubeConfig is invoked inside the factory so it is only called on cache misses. - * @param context - Optional canonical cluster ID - */ -export async function getCustomObjectsApi( - context?: string, - reqCache?: ReqCache, - timeoutMs = OPERATION_TIMEOUTS.list -): Promise { - const key = normalizeClusterId(context); - return getOrCreate(customObjectsPool, `${key}:${timeoutMs}`, async () => { - const config = await getKubeConfig(key, reqCache); - return makeApiClientWithTimeout(config, k8s.CustomObjectsApi, timeoutMs); - }); -} - -/** - * Create CoreV1Api client, reusing a pooled instance when possible. - * getKubeConfig is invoked inside the factory so it is only called on cache misses. - * @param context - Optional canonical cluster ID - */ -export async function getCoreV1Api( - context?: string, - reqCache?: ReqCache, - timeoutMs = OPERATION_TIMEOUTS.get -): Promise { - const key = normalizeClusterId(context); - return getOrCreate(coreV1Pool, `${key}:${timeoutMs}`, async () => { - const config = await getKubeConfig(key, reqCache); - return makeApiClientWithTimeout(config, k8s.CoreV1Api, timeoutMs); - }); -} - -/** - * Create AppsV1Api client, reusing a pooled instance when possible. - * getKubeConfig is invoked inside the factory so it is only called on cache misses. - * @param context - Optional canonical cluster ID - */ -export async function getAppsV1Api( - context?: string, - reqCache?: ReqCache, - timeoutMs = OPERATION_TIMEOUTS.get -): Promise { - const key = normalizeClusterId(context); - return getOrCreate(appsV1Pool, `${key}:${timeoutMs}`, async () => { - const config = await getKubeConfig(key, reqCache); - return makeApiClientWithTimeout(config, k8s.AppsV1Api, timeoutMs); - }); -} - -/** - * List FluxCD resources of a specific type across all namespaces - * Supports pagination (limit/offset) and server-side sorting. - */ -export async function listFluxResources( - resourceType: FluxResourceType, - context?: string, - reqCache?: ReqCache, - options?: ListOptions -): Promise { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - try { - const api = await getCustomObjectsApi(context, reqCache); - - // Sorting requires the full collection (k8s only sorts by name natively). - // When no sort is requested and a limit is provided, delegate paging to - // the k8s API so only the requested page is transferred over the network. - const useNativePaging = - !options?.sortBy && options?.limit !== undefined && (options?.offset ?? 0) === 0; - - const response = await api.listClusterCustomObject({ - group: resourceDef.group, - version: resourceDef.version, - plural: resourceDef.plural, - ...(useNativePaging ? { limit: options!.limit } : {}) - }); - - const list = response as unknown as FluxResourceList; - const items = list.items ?? []; - - if (useNativePaging) { - // k8s already returned the page; metadata.continue signals more pages. - return { - items, - total: null, // exact total unknown with cursor-based k8s paging; use hasMore instead - hasMore: !!list.metadata?.continue, - offset: 0, - limit: options!.limit!, - metadata: { - resourceVersion: list.metadata?.resourceVersion, - continueToken: list.metadata?.continue - } - }; - } - - // Full-fetch path: sort (if requested) then slice. - const sorted = options?.sortBy - ? sortResources(items, options.sortBy, options.sortOrder) - : items; - - const total = sorted.length; - const offset = options?.offset ?? 0; - const paginatedItems = - options?.limit !== undefined - ? sorted.slice(offset, offset + options.limit) - : sorted.slice(offset); - const effectiveLimit = paginatedItems.length; - - return { - items: paginatedItems, - total, - hasMore: options?.limit !== undefined ? offset + options.limit < total : false, - offset, - limit: effectiveLimit, - metadata: { - resourceVersion: list.metadata?.resourceVersion - } - }; - } catch (error) { - throw handleK8sError(error, `list ${resourceType}`); - } -} - -/** - * List FluxCD resources of a specific type in a namespace - */ -export async function listFluxResourcesInNamespace( - resourceType: FluxResourceType, - namespace: string, - context?: string, - reqCache?: ReqCache -): Promise { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - try { - const api = await getCustomObjectsApi(context, reqCache); - const response = await api.listNamespacedCustomObject({ - group: resourceDef.group, - version: resourceDef.version, - namespace, - plural: resourceDef.plural - }); - - return response as unknown as FluxResourceList; - } catch (error) { - throw handleK8sError(error, `list ${resourceType} in namespace ${namespace}`); - } -} - -/** - * Poll for changes to FluxCD resources of a specific type across all namespaces. - * Returns an async iterable that yields resources when changes are detected. - * Includes automatic reconnection on failure with exponential backoff. - * - * Note: This implements polling-based change detection since the Kubernetes - * client-node library's watch API has limitations for custom resources. - * For production use, consider implementing server-sent events (SSE) or WebSocket - * based on the Kubernetes Watch API for true real-time updates. - * - * @param resourceType - Type of Flux resource to watch - * @param context - Optional cluster ID or context name - * @param pollIntervalMs - Interval between polls (default: 5000ms) - */ -export async function* watchFluxResources( - resourceType: FluxResourceType, - context?: string, - pollIntervalMs = 5000 -): AsyncGenerator { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - let lastResourceVersion: string | undefined; - let reconnectAttempts = 0; - const maxReconnectAttempts = 10; - const baseBackoffMs = 1000; - - while (reconnectAttempts < maxReconnectAttempts) { - try { - const result = await listFluxResources(resourceType, context); - - // Reset reconnect counter on any successful poll (not just version changes) - reconnectAttempts = 0; - - // Check if resource version changed - const currentVersion = result.metadata?.resourceVersion; - if (currentVersion !== lastResourceVersion) { - lastResourceVersion = currentVersion; - yield result; - } - - // Wait before next poll - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - - reconnectAttempts++; - if (reconnectAttempts < maxReconnectAttempts) { - // Exponential backoff: 1s, 2s, 4s, 8s, etc., capped at 30s - const backoffMs = Math.min(baseBackoffMs * Math.pow(2, reconnectAttempts - 1), 30_000); - logger.warn( - `Poll for ${resourceType} failed, retrying in ${backoffMs}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`, - err - ); - await new Promise((resolve) => setTimeout(resolve, backoffMs)); - } else { - logger.error(`Poll for ${resourceType} failed after ${maxReconnectAttempts} attempts`); - throw err; - } - } - } -} - -/** - * Get a specific FluxCD resource - */ -export async function getFluxResource( - resourceType: FluxResourceType, - namespace: string, - name: string, - context?: string, - reqCache?: ReqCache -): Promise { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - try { - const api = await getCustomObjectsApi(context, reqCache); - const response = await api.getNamespacedCustomObject({ - group: resourceDef.group, - version: resourceDef.version, - namespace, - plural: resourceDef.plural, - name - }); - - return response as unknown as FluxResource; - } catch (error) { - throw handleK8sError(error, `get ${resourceType} ${namespace}/${name}`); - } -} - -/** - * Get resource status (uses getNamespacedCustomObjectStatus) - */ -export async function getFluxResourceStatus( - resourceType: FluxResourceType, - namespace: string, - name: string, - context?: string, - reqCache?: ReqCache -): Promise { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - try { - const api = await getCustomObjectsApi(context, reqCache); - const response = await api.getNamespacedCustomObjectStatus({ - group: resourceDef.group, - version: resourceDef.version, - namespace, - plural: resourceDef.plural, - name - }); - - return response as unknown as FluxResource; - } catch (error) { - throw handleK8sError(error, `get status for ${resourceType} ${namespace}/${name}`); - } -} - -/** - * Create a FluxCD resource - */ -export async function createFluxResource( - resourceType: FluxResourceType, - namespace: string, - body: Record, - context?: string, - reqCache?: ReqCache -): Promise { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - try { - const api = await getCustomObjectsApi(context, reqCache); - const response = await api.createNamespacedCustomObject({ - group: resourceDef.group, - version: resourceDef.version, - namespace, - plural: resourceDef.plural, - body: body as object - }); - - return response as unknown as FluxResource; - } catch (error) { - throw handleK8sError(error, `create ${resourceType} in ${namespace}`); - } -} - -/** - * Update (replace) a FluxCD resource - */ -export async function updateFluxResource( - resourceType: FluxResourceType, - namespace: string, - name: string, - body: Record, - context?: string, - reqCache?: ReqCache -): Promise { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - try { - const api = await getCustomObjectsApi(context, reqCache); - const response = await api.replaceNamespacedCustomObject({ - group: resourceDef.group, - version: resourceDef.version, - namespace, - plural: resourceDef.plural, - name, - body: body as object - }); - - return response as unknown as FluxResource; - } catch (error) { - throw handleK8sError(error, `update ${resourceType} ${namespace}/${name}`); - } -} - -/** - * Delete a FluxCD resource with timeout protection. - * Gracefully handles 404 Not Found errors (idempotent deletion). - */ -export async function deleteFluxResource( - resourceType: FluxResourceType, - namespace: string, - name: string, - context?: string, - reqCache?: ReqCache -): Promise { - const resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - try { - const api = await getCustomObjectsApi(context, reqCache, OPERATION_TIMEOUTS.delete); - await api.deleteNamespacedCustomObject({ - group: resourceDef.group, - version: resourceDef.version, - namespace, - plural: resourceDef.plural, - name - }); - } catch (error) { - // Treat 404 as success (idempotent) — resource doesn't exist, which is the desired end state - if (error instanceof Error && (error as Error & { code?: number }).code === 404) { - return; - } - throw handleK8sError(error, `delete ${resourceType} ${namespace}/${name}`); - } -} - -/** - * Batch delete multiple FluxCD resources with configurable concurrency. - * Continues on individual failures and returns detailed results. - * @param items - Array of {resourceType, namespace, name} to delete - * @param context - Optional cluster context - * @param concurrency - Maximum concurrent delete operations (default: 5) - */ -export interface DeleteItem { - resourceType: FluxResourceType; - namespace: string; - name: string; -} - -export interface DeleteResult { - item: DeleteItem; - success: boolean; - error?: Error; -} - -export async function deleteFluxResourcesBatch( - items: DeleteItem[], - context?: string, - concurrency = 5 -): Promise { - // Validate concurrency to prevent semaphore deadlock - if (concurrency <= 0) { - throw new RangeError('concurrency must be greater than 0'); - } - - // Allocate results array with same length as items to preserve ordering - const results: Array = Array.from({ length: items.length }); - const semaphore = { active: 0, queue: [] as Array<(value: void) => void> }; - - const executeWithConcurrency = async (item: DeleteItem, index: number) => { - // Wait for a slot to become available - while (semaphore.active >= concurrency) { - await new Promise((resolve) => { - semaphore.queue.push(resolve); - }); - } - - semaphore.active++; - try { - await deleteFluxResource(item.resourceType, item.namespace, item.name, context); - results[index] = { item, success: true }; - } catch (error) { - results[index] = { - item, - success: false, - error: error instanceof Error ? error : new Error(String(error)) - }; - } finally { - semaphore.active--; - const next = semaphore.queue.shift(); - if (next) next(undefined); - } - }; - - // Start all delete operations with index to preserve order - const promises = items.map((item, index) => executeWithConcurrency(item, index)); - await Promise.all(promises); - - // Filter out undefined entries (should none exist in normal case) - return results.filter((r): r is DeleteResult => r !== undefined); -} - -/** - * Get logs for a FluxCD controller responsible for a specific resource - */ -export async function getControllerLogs( - resourceType: string, - namespace: string, - name: string, - context?: string, - reqCache?: ReqCache -): Promise { - let resourceDef = getResourceDef(resourceType); - if (!resourceDef) { - const key = getResourceTypeByPlural(resourceType); - if (key) { - resourceDef = getResourceDef(key); - } - } - - if (!resourceDef) { - throw new Error(`Unknown resource type: ${resourceType}`); - } - - const controllerName = resourceDef.controller; - - try { - const coreApi = await getCoreV1Api(context, reqCache, OPERATION_TIMEOUTS.logs); - // 1. Find the controller pod in flux-system namespace - // Most Flux installations use the app label - const podsResponse = await coreApi.listNamespacedPod({ - namespace: 'flux-system', - labelSelector: `app=${controllerName}` - }); - - const pods = podsResponse.items; - if (pods.length === 0) { - // Fallback: try app.kubernetes.io/name label - const podsResponseAlt = await coreApi.listNamespacedPod({ - namespace: 'flux-system', - labelSelector: `app.kubernetes.io/name=${controllerName}` - }); - if (podsResponseAlt.items.length === 0) { - throw new Error(`No pods found for controller ${controllerName} in namespace flux-system`); - } - pods.push(...podsResponseAlt.items); - } - - // Pick the first running pod - const pod = pods.find((p) => p.status?.phase === 'Running') || pods[0]; - const podName = pod.metadata?.name; - - if (!podName) { - throw new Error(`Could not determine pod name for controller ${controllerName}`); - } - - // 2. Fetch logs (last 500 lines) - const logsResponse = await coreApi.readNamespacedPodLog({ - name: podName, - namespace: 'flux-system', - tailLines: 1000 - }); - - const logs = logsResponse; - - // 3. Filter logs for the specific resource - // Flux logs are JSON and typically contain "name" and "namespace" fields for the resource being processed. - // We grep for both to be as specific as possible. - const lines = logs.split('\n'); - const filteredLines = lines.filter((line) => { - if (!line.trim()) return false; - // Match both name and namespace of the resource - return line.includes(`"${name}"`) && line.includes(`"${namespace}"`); - }); - - // If filtering yields too little, return more context or all logs - if (filteredLines.length < 10) { - return logs; - } - - return filteredLines.join('\n'); - } catch (error) { - throw handleK8sError(error, `fetch logs for ${controllerName}`, OPERATION_TIMEOUTS.logs); - } -} - -/** - * Handle Kubernetes API errors - */ -export function handleK8sError( - error: unknown, - operation: string, - timeoutMs = DEFAULT_TIMEOUT_MS -): Error { - // Log the full error server-side for debugging - logger.error(error, `Kubernetes API error during ${operation}`); - - if (error instanceof Error) { - // Detect AbortController-triggered timeouts (node-fetch surfaces these as - // AbortError or as a generic Error with name 'AbortError'). - if ( - error.name === 'AbortError' || - error instanceof k8s.AbortError || - (error as { type?: string }).type === 'aborted' - ) { - return new KubernetesTimeoutError(operation, timeoutMs); - } - - // @kubernetes/client-node v1 throws ApiException with a `code` property directly - const apiException = error as Error & { code?: number | string }; - // Older versions used `response.statusCode` - const k8sError = error as Error & { - response?: { statusCode: number; body?: { message?: string } }; - errno?: string; - }; - - // Check for connection-related errors - const connectionErrors = [ - 'ECONNREFUSED', - 'ETIMEDOUT', - 'ENOTFOUND', - 'EHOSTUNREACH', - 'ESOCKETTIMEDOUT', - 'ECONNRESET' - ]; - - const errorCode = apiException.code?.toString() ?? k8sError.errno; - if (errorCode && connectionErrors.includes(errorCode)) { - return new ClusterUnavailableError(`Kubernetes cluster is unavailable: ${errorCode}`); - } - - const status = - typeof apiException.code === 'number' ? apiException.code : k8sError.response?.statusCode; - - if (status !== undefined) { - switch (status) { - case 404: - return new ResourceNotFoundError(operation); - case 401: - return new AuthenticationError(`Authentication failed: ${operation}`); - case 403: - return new AuthorizationError(`Permission denied: ${operation}`); - case 503: - case 504: - return new ClusterUnavailableError(`Kubernetes cluster is unavailable (${status})`); - default: - return new KubernetesError(`Kubernetes API error (${status})`, status, 'ApiError'); - } - } - return new KubernetesError(`Failed to ${operation}: Internal Error`, 500, 'InternalError'); - } - return new KubernetesError(`Failed to ${operation}: Unknown error`, 500, 'UnknownError'); -} - -// Note: Signal handlers (SIGTERM/SIGINT) should be registered in the centralized -// application shutdown flow, not here. This allows the app to coordinate shutdown -// across all components (database, servers, etc.) in the correct order. -// Call gracefulShutdown() from the main shutdown orchestrator (e.g., shutdownGyre). +export * from './timeouts.js'; +export * from './client-factory.js'; +export * from './client-pool.js'; +export * from './kubeconfig-provider.js'; +export * from './secret-access.js'; +export * from './error-handler.js'; +export * from './flux/listing.js'; +export * from './flux/crud.js'; +export * from './flux/logs.js'; diff --git a/src/lib/server/kubernetes/error-handler.ts b/src/lib/server/kubernetes/error-handler.ts new file mode 100644 index 00000000..659a64db --- /dev/null +++ b/src/lib/server/kubernetes/error-handler.ts @@ -0,0 +1,79 @@ +import { logger } from '../logger.js'; +import * as k8s from '@kubernetes/client-node'; +import { + AuthenticationError, + AuthorizationError, + ClusterUnavailableError, + KubernetesError, + KubernetesTimeoutError, + ResourceNotFoundError +} from './errors.js'; +import { DEFAULT_TIMEOUT_MS } from './timeouts.js'; + +/** + * Handle Kubernetes API errors + */ +export function handleK8sError( + error: unknown, + operation: string, + timeoutMs = DEFAULT_TIMEOUT_MS +): Error { + // Log the full error server-side for debugging + logger.error(error, `Kubernetes API error during ${operation}`); + + if (error instanceof Error) { + // Detect AbortController-triggered timeouts (node-fetch surfaces these as + // AbortError or as a generic Error with name 'AbortError'). + if ( + error.name === 'AbortError' || + error instanceof k8s.AbortError || + (error as { type?: string }).type === 'aborted' + ) { + return new KubernetesTimeoutError(operation, timeoutMs); + } + + // @kubernetes/client-node v1 throws ApiException with a `code` property directly + const apiException = error as Error & { code?: number | string }; + // Older versions used `response.statusCode` + const k8sError = error as Error & { + response?: { statusCode: number; body?: { message?: string } }; + errno?: string; + }; + + // Check for connection-related errors + const connectionErrors = [ + 'ECONNREFUSED', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EHOSTUNREACH', + 'ESOCKETTIMEDOUT', + 'ECONNRESET' + ]; + + const errorCode = apiException.code?.toString() ?? k8sError.errno; + if (errorCode && connectionErrors.includes(errorCode)) { + return new ClusterUnavailableError(`Kubernetes cluster is unavailable: ${errorCode}`); + } + + const status = + typeof apiException.code === 'number' ? apiException.code : k8sError.response?.statusCode; + + if (status !== undefined) { + switch (status) { + case 404: + return new ResourceNotFoundError(operation); + case 401: + return new AuthenticationError(`Authentication failed: ${operation}`); + case 403: + return new AuthorizationError(`Permission denied: ${operation}`); + case 503: + case 504: + return new ClusterUnavailableError(`Kubernetes cluster is unavailable (${status})`); + default: + return new KubernetesError(`Kubernetes API error (${status})`, status, 'ApiError'); + } + } + return new KubernetesError(`Failed to ${operation}: Internal Error`, 500, 'InternalError'); + } + return new KubernetesError(`Failed to ${operation}: Unknown error`, 500, 'UnknownError'); +} diff --git a/src/lib/server/kubernetes/flux/crud.ts b/src/lib/server/kubernetes/flux/crud.ts new file mode 100644 index 00000000..2a751904 --- /dev/null +++ b/src/lib/server/kubernetes/flux/crud.ts @@ -0,0 +1,232 @@ +import { getResourceDef, type FluxResourceType } from './resources.js'; +import type { FluxResource } from './types.js'; +import { getCustomObjectsApi } from '../client-pool.js'; +import type { ReqCache } from '../kubeconfig-provider.js'; +import { handleK8sError } from '../error-handler.js'; +import { OPERATION_TIMEOUTS } from '../timeouts.js'; + +/** + * Get a specific FluxCD resource + */ +export async function getFluxResource( + resourceType: FluxResourceType, + namespace: string, + name: string, + context?: string, + reqCache?: ReqCache +): Promise { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + try { + const api = await getCustomObjectsApi(context, reqCache); + const response = await api.getNamespacedCustomObject({ + group: resourceDef.group, + version: resourceDef.version, + namespace, + plural: resourceDef.plural, + name + }); + + return response as unknown as FluxResource; + } catch (error) { + throw handleK8sError(error, `get ${resourceType} ${namespace}/${name}`); + } +} + +/** + * Get resource status (uses getNamespacedCustomObjectStatus) + */ +export async function getFluxResourceStatus( + resourceType: FluxResourceType, + namespace: string, + name: string, + context?: string, + reqCache?: ReqCache +): Promise { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + try { + const api = await getCustomObjectsApi(context, reqCache); + const response = await api.getNamespacedCustomObjectStatus({ + group: resourceDef.group, + version: resourceDef.version, + namespace, + plural: resourceDef.plural, + name + }); + + return response as unknown as FluxResource; + } catch (error) { + throw handleK8sError(error, `get status for ${resourceType} ${namespace}/${name}`); + } +} + +/** + * Create a FluxCD resource + */ +export async function createFluxResource( + resourceType: FluxResourceType, + namespace: string, + body: Record, + context?: string, + reqCache?: ReqCache +): Promise { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + try { + const api = await getCustomObjectsApi(context, reqCache); + const response = await api.createNamespacedCustomObject({ + group: resourceDef.group, + version: resourceDef.version, + namespace, + plural: resourceDef.plural, + body: body as object + }); + + return response as unknown as FluxResource; + } catch (error) { + throw handleK8sError(error, `create ${resourceType} in ${namespace}`); + } +} + +/** + * Update (replace) a FluxCD resource + */ +export async function updateFluxResource( + resourceType: FluxResourceType, + namespace: string, + name: string, + body: Record, + context?: string, + reqCache?: ReqCache +): Promise { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + try { + const api = await getCustomObjectsApi(context, reqCache); + const response = await api.replaceNamespacedCustomObject({ + group: resourceDef.group, + version: resourceDef.version, + namespace, + plural: resourceDef.plural, + name, + body: body as object + }); + + return response as unknown as FluxResource; + } catch (error) { + throw handleK8sError(error, `update ${resourceType} ${namespace}/${name}`); + } +} + +/** + * Delete a FluxCD resource with timeout protection. + * Gracefully handles 404 Not Found errors (idempotent deletion). + */ +export async function deleteFluxResource( + resourceType: FluxResourceType, + namespace: string, + name: string, + context?: string, + reqCache?: ReqCache +): Promise { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + try { + const api = await getCustomObjectsApi(context, reqCache, OPERATION_TIMEOUTS.delete); + await api.deleteNamespacedCustomObject({ + group: resourceDef.group, + version: resourceDef.version, + namespace, + plural: resourceDef.plural, + name + }); + } catch (error) { + // Treat 404 as success (idempotent) — resource doesn't exist, which is the desired end state + if (error instanceof Error && (error as Error & { code?: number }).code === 404) { + return; + } + throw handleK8sError(error, `delete ${resourceType} ${namespace}/${name}`); + } +} + +/** + * Batch delete multiple FluxCD resources with configurable concurrency. + * Continues on individual failures and returns detailed results. + * @param items - Array of {resourceType, namespace, name} to delete + * @param context - Optional cluster context + * @param concurrency - Maximum concurrent delete operations (default: 5) + */ +export interface DeleteItem { + resourceType: FluxResourceType; + namespace: string; + name: string; +} + +export interface DeleteResult { + item: DeleteItem; + success: boolean; + error?: Error; +} + +export async function deleteFluxResourcesBatch( + items: DeleteItem[], + context?: string, + concurrency = 5 +): Promise { + // Validate concurrency to prevent semaphore deadlock + if (concurrency <= 0) { + throw new RangeError('concurrency must be greater than 0'); + } + + // Allocate results array with same length as items to preserve ordering + const results: Array = Array.from({ length: items.length }); + const semaphore = { active: 0, queue: [] as Array<(value: void) => void> }; + + const executeWithConcurrency = async (item: DeleteItem, index: number) => { + // Wait for a slot to become available + while (semaphore.active >= concurrency) { + await new Promise((resolve) => { + semaphore.queue.push(resolve); + }); + } + + semaphore.active++; + try { + await deleteFluxResource(item.resourceType, item.namespace, item.name, context); + results[index] = { item, success: true }; + } catch (error) { + results[index] = { + item, + success: false, + error: error instanceof Error ? error : new Error(String(error)) + }; + } finally { + semaphore.active--; + const next = semaphore.queue.shift(); + if (next) next(undefined); + } + }; + + // Start all delete operations with index to preserve order + const promises = items.map((item, index) => executeWithConcurrency(item, index)); + await Promise.all(promises); + + // Filter out undefined entries (should none exist in normal case) + return results.filter((r): r is DeleteResult => r !== undefined); +} diff --git a/src/lib/server/kubernetes/flux/listing.ts b/src/lib/server/kubernetes/flux/listing.ts new file mode 100644 index 00000000..f8879c1f --- /dev/null +++ b/src/lib/server/kubernetes/flux/listing.ts @@ -0,0 +1,268 @@ +import { logger } from '../../logger.js'; +import { getResourceDef, type FluxResourceType } from './resources.js'; +import type { FluxResource, FluxResourceList } from './types.js'; +import { getCustomObjectsApi } from '../client-pool.js'; +import type { ReqCache } from '../kubeconfig-provider.js'; +import { handleK8sError } from '../error-handler.js'; + +export interface ListOptions { + limit?: number; + offset?: number; + sortBy?: 'name' | 'age' | 'status'; + sortOrder?: 'asc' | 'desc'; +} + +export interface PaginatedFluxResourceList { + items: FluxResource[]; + /** Exact total, or null when cursor-based native paging was used and the total is unknown. */ + total: number | null; + hasMore: boolean; + offset: number; + limit: number; + metadata: { + resourceVersion?: string; + /** k8s continue token; present only when native k8s paging was used. */ + continueToken?: string; + }; +} + +const STATUS_ORDER: Record = { + failed: 0, + progressing: 1, + suspended: 2, + unknown: 3, + healthy: 4 +}; + +function getResourceStatus(resource: FluxResource): string { + if (resource.spec?.suspend) return 'suspended'; + const conditions = resource.status?.conditions; + if (!conditions || conditions.length === 0) return 'unknown'; + const stalled = conditions.find((c) => c.type === 'Stalled' || c.type === 'Failed'); + if (stalled?.status === 'True') return 'failed'; + const gen = resource.metadata.generation; + const obsGen = resource.status?.observedGeneration; + if (gen !== undefined && obsGen !== undefined && obsGen < gen) return 'progressing'; + for (const type of ['Ready', 'Healthy', 'Succeeded', 'Available']) { + const cond = conditions.find((c) => c.type === type); + if (cond) { + if (cond.status === 'True') return 'healthy'; + if ( + cond.status === 'False' && + (cond.reason === 'Progressing' || + cond.reason === 'ProgressingWithRetry' || + cond.reason === 'DependencyNotReady' || + cond.reason === 'ReconciliationInProgress') + ) { + return 'progressing'; + } + if (cond.status === 'False') return 'failed'; + } + } + return 'unknown'; +} + +function sortResources( + items: FluxResource[], + sortBy: ListOptions['sortBy'], + sortOrder: ListOptions['sortOrder'] = 'asc' +): FluxResource[] { + const sorted = [...items].sort((a, b) => { + let cmp = 0; + if (sortBy === 'name') { + cmp = (a.metadata.name ?? '').localeCompare(b.metadata.name ?? ''); + } else if (sortBy === 'age') { + const aTime = a.metadata.creationTimestamp + ? new Date(a.metadata.creationTimestamp).getTime() + : 0; + const bTime = b.metadata.creationTimestamp + ? new Date(b.metadata.creationTimestamp).getTime() + : 0; + cmp = aTime - bTime; + } else if (sortBy === 'status') { + const aOrder = STATUS_ORDER[getResourceStatus(a)] ?? 3; + const bOrder = STATUS_ORDER[getResourceStatus(b)] ?? 3; + cmp = aOrder - bOrder; + } + // Deterministic tie-breaker: compare uid, fall back to name. + // Applied before direction inversion so sortOrder is respected consistently. + if (cmp === 0) { + cmp = (a.metadata.uid ?? a.metadata.name ?? '').localeCompare( + b.metadata.uid ?? b.metadata.name ?? '' + ); + } + return sortOrder === 'desc' ? -cmp : cmp; + }); + return sorted; +} + +/** + * List FluxCD resources of a specific type across all namespaces + * Supports pagination (limit/offset) and server-side sorting. + */ +export async function listFluxResources( + resourceType: FluxResourceType, + context?: string, + reqCache?: ReqCache, + options?: ListOptions +): Promise { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + try { + const api = await getCustomObjectsApi(context, reqCache); + + // Sorting requires the full collection (k8s only sorts by name natively). + // When no sort is requested and a limit is provided, delegate paging to + // the k8s API so only the requested page is transferred over the network. + const useNativePaging = + !options?.sortBy && options?.limit !== undefined && (options?.offset ?? 0) === 0; + + const response = await api.listClusterCustomObject({ + group: resourceDef.group, + version: resourceDef.version, + plural: resourceDef.plural, + ...(useNativePaging ? { limit: options!.limit } : {}) + }); + + const list = response as unknown as FluxResourceList; + const items = list.items ?? []; + + if (useNativePaging) { + // k8s already returned the page; metadata.continue signals more pages. + return { + items, + total: null, // exact total unknown with cursor-based k8s paging; use hasMore instead + hasMore: !!list.metadata?.continue, + offset: 0, + limit: options!.limit!, + metadata: { + resourceVersion: list.metadata?.resourceVersion, + continueToken: list.metadata?.continue + } + }; + } + + // Full-fetch path: sort (if requested) then slice. + const sorted = options?.sortBy + ? sortResources(items, options.sortBy, options.sortOrder) + : items; + + const total = sorted.length; + const offset = options?.offset ?? 0; + const paginatedItems = + options?.limit !== undefined + ? sorted.slice(offset, offset + options.limit) + : sorted.slice(offset); + const effectiveLimit = paginatedItems.length; + + return { + items: paginatedItems, + total, + hasMore: options?.limit !== undefined ? offset + options.limit < total : false, + offset, + limit: effectiveLimit, + metadata: { + resourceVersion: list.metadata?.resourceVersion + } + }; + } catch (error) { + throw handleK8sError(error, `list ${resourceType}`); + } +} + +/** + * List FluxCD resources of a specific type in a namespace + */ +export async function listFluxResourcesInNamespace( + resourceType: FluxResourceType, + namespace: string, + context?: string, + reqCache?: ReqCache +): Promise { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + try { + const api = await getCustomObjectsApi(context, reqCache); + const response = await api.listNamespacedCustomObject({ + group: resourceDef.group, + version: resourceDef.version, + namespace, + plural: resourceDef.plural + }); + + return response as unknown as FluxResourceList; + } catch (error) { + throw handleK8sError(error, `list ${resourceType} in namespace ${namespace}`); + } +} + +/** + * Poll for changes to FluxCD resources of a specific type across all namespaces. + * Returns an async iterable that yields resources when changes are detected. + * Includes automatic reconnection on failure with exponential backoff. + * + * Note: This implements polling-based change detection since the Kubernetes + * client-node library's watch API has limitations for custom resources. + * For production use, consider implementing server-sent events (SSE) or WebSocket + * based on the Kubernetes Watch API for true real-time updates. + * + * @param resourceType - Type of Flux resource to watch + * @param context - Optional cluster ID or context name + * @param pollIntervalMs - Interval between polls (default: 5000ms) + */ +export async function* watchFluxResources( + resourceType: FluxResourceType, + context?: string, + pollIntervalMs = 5000 +): AsyncGenerator { + const resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + let lastResourceVersion: string | undefined; + let reconnectAttempts = 0; + const maxReconnectAttempts = 10; + const baseBackoffMs = 1000; + + while (reconnectAttempts < maxReconnectAttempts) { + try { + const result = await listFluxResources(resourceType, context); + + // Reset reconnect counter on any successful poll (not just version changes) + reconnectAttempts = 0; + + // Check if resource version changed + const currentVersion = result.metadata?.resourceVersion; + if (currentVersion !== lastResourceVersion) { + lastResourceVersion = currentVersion; + yield result; + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + + reconnectAttempts++; + if (reconnectAttempts < maxReconnectAttempts) { + // Exponential backoff: 1s, 2s, 4s, 8s, etc., capped at 30s + const backoffMs = Math.min(baseBackoffMs * Math.pow(2, reconnectAttempts - 1), 30_000); + logger.warn( + `Poll for ${resourceType} failed, retrying in ${backoffMs}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`, + err + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } else { + logger.error(`Poll for ${resourceType} failed after ${maxReconnectAttempts} attempts`); + throw err; + } + } + } +} diff --git a/src/lib/server/kubernetes/flux/logs.ts b/src/lib/server/kubernetes/flux/logs.ts new file mode 100644 index 00000000..05a46462 --- /dev/null +++ b/src/lib/server/kubernetes/flux/logs.ts @@ -0,0 +1,88 @@ +import { getResourceDef, getResourceTypeByPlural } from './resources.js'; +import { getCoreV1Api } from '../client-pool.js'; +import type { ReqCache } from '../kubeconfig-provider.js'; +import { handleK8sError } from '../error-handler.js'; +import { OPERATION_TIMEOUTS } from '../timeouts.js'; + +/** + * Get logs for a FluxCD controller responsible for a specific resource + */ +export async function getControllerLogs( + resourceType: string, + namespace: string, + name: string, + context?: string, + reqCache?: ReqCache +): Promise { + let resourceDef = getResourceDef(resourceType); + if (!resourceDef) { + const key = getResourceTypeByPlural(resourceType); + if (key) { + resourceDef = getResourceDef(key); + } + } + + if (!resourceDef) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + const controllerName = resourceDef.controller; + + try { + const coreApi = await getCoreV1Api(context, reqCache, OPERATION_TIMEOUTS.logs); + // 1. Find the controller pod in flux-system namespace + // Most Flux installations use the app label + const podsResponse = await coreApi.listNamespacedPod({ + namespace: 'flux-system', + labelSelector: `app=${controllerName}` + }); + + const pods = podsResponse.items; + if (pods.length === 0) { + // Fallback: try app.kubernetes.io/name label + const podsResponseAlt = await coreApi.listNamespacedPod({ + namespace: 'flux-system', + labelSelector: `app.kubernetes.io/name=${controllerName}` + }); + if (podsResponseAlt.items.length === 0) { + throw new Error(`No pods found for controller ${controllerName} in namespace flux-system`); + } + pods.push(...podsResponseAlt.items); + } + + // Pick the first running pod + const pod = pods.find((p) => p.status?.phase === 'Running') || pods[0]; + const podName = pod.metadata?.name; + + if (!podName) { + throw new Error(`Could not determine pod name for controller ${controllerName}`); + } + + // 2. Fetch logs (last 500 lines) + const logsResponse = await coreApi.readNamespacedPodLog({ + name: podName, + namespace: 'flux-system', + tailLines: 1000 + }); + + const logs = logsResponse; + + // 3. Filter logs for the specific resource + // Flux logs are JSON and typically contain "name" and "namespace" fields for the resource being processed. + // We grep for both to be as specific as possible. + const lines = logs.split('\n'); + const filteredLines = lines.filter((line) => { + if (!line.trim()) return false; + // Match both name and namespace of the resource + return line.includes(`"${name}"`) && line.includes(`"${namespace}"`); + }); + + if (filteredLines.length === 0) { + return `No controller log lines matched ${resourceType} ${namespace}/${name}.`; + } + + return filteredLines.join('\n'); + } catch (error) { + throw handleK8sError(error, `fetch logs for ${controllerName}`, OPERATION_TIMEOUTS.logs); + } +} diff --git a/src/lib/server/kubernetes/kubeconfig-provider.ts b/src/lib/server/kubernetes/kubeconfig-provider.ts new file mode 100644 index 00000000..54500456 --- /dev/null +++ b/src/lib/server/kubernetes/kubeconfig-provider.ts @@ -0,0 +1,81 @@ +import { logger } from '../logger.js'; +import { IN_CLUSTER_ID, normalizeClusterId } from '$lib/clusters/identity.js'; +import * as k8s from '@kubernetes/client-node'; +import { loadKubeConfig } from './config.js'; +import { getClusterKubeconfig } from '../clusters/repository.js'; + +// Store the base default config separately to avoid reloading it constantly. +// Cache scope: process-local base in-cluster config; cleared during graceful shutdown. +let baseConfig: k8s.KubeConfig | null = null; + +// Request cache scope: per-request only, success-only, naturally discarded at request end. +export type ReqCache = Map>; + +/** + * Get or create KubeConfig for a specific canonical cluster ID. + * Only caches successful configs; failed promises are not cached to allow retries. + * @param clusterId - Optional cluster ID. undefined/default/in-cluster select the runtime config. + */ +export async function getKubeConfig( + clusterId?: string, + reqCache?: ReqCache +): Promise { + const key = normalizeClusterId(clusterId); + + // Check cache for successful configs only + if (reqCache && reqCache.has(key)) { + const cachedPromise = reqCache.get(key)!; + // Verify the cached promise hasn't rejected + // Note: We return the promise as-is; if it rejected, caller will handle the rejection + return cachedPromise; + } + + const loadConfig = async () => { + let config: k8s.KubeConfig; + + if (key === IN_CLUSTER_ID) { + if (!baseConfig) { + baseConfig = loadKubeConfig(); + } + config = new k8s.KubeConfig(); + config.loadFromString(baseConfig.exportConfig()); + } else { + const kubeconfigYaml = await getClusterKubeconfig(key); + if (!kubeconfigYaml) { + throw new Error(`Cluster with ID "${key}" not found or has no valid configuration`); + } + config = new k8s.KubeConfig(); + config.loadFromString(kubeconfigYaml); + logger.debug(`✓ Loaded Kubernetes configuration from database for cluster: ${key}`); + } + + return config; + }; + + // Wrap promise handling to implement success-only caching with concurrent deduplication + if (reqCache) { + // Create the promise + const cachedPromise = loadConfig() + .then((config) => { + // On success, cache the resolved promise for future calls + reqCache.set(key, Promise.resolve(config)); + return config; + }) + .catch((error) => { + // On failure, ensure no stale cache entry exists, allowing retries + reqCache.delete(key); + throw error; + }); + + // Store the in-flight promise immediately so concurrent callers reuse it + reqCache.set(key, cachedPromise); + + return cachedPromise; + } + + return loadConfig(); +} + +export function clearBaseKubeConfig(): void { + baseConfig = null; +} diff --git a/src/lib/server/kubernetes/namespace.ts b/src/lib/server/kubernetes/namespace.ts new file mode 100644 index 00000000..2db2dd7d --- /dev/null +++ b/src/lib/server/kubernetes/namespace.ts @@ -0,0 +1,12 @@ +import { readFileSync } from 'node:fs'; + +export const IN_CLUSTER_NAMESPACE_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/namespace'; + +export function getCurrentNamespace(): string { + try { + const namespace = readFileSync(IN_CLUSTER_NAMESPACE_PATH, 'utf-8').trim(); + return namespace || 'default'; + } catch { + return 'default'; + } +} diff --git a/src/lib/server/kubernetes/secret-access.ts b/src/lib/server/kubernetes/secret-access.ts new file mode 100644 index 00000000..0f2050ed --- /dev/null +++ b/src/lib/server/kubernetes/secret-access.ts @@ -0,0 +1,56 @@ +import { normalizeClusterId } from '$lib/clusters/identity.js'; +import * as k8s from '@kubernetes/client-node'; +import { logger } from '../logger.js'; +import { getCoreV1Api } from './client-pool.js'; +import { handleK8sError } from './error-handler.js'; + +/** + * Audit log helper for sensitive resource access (e.g., Secrets). + * Used to track compliance requirements for access to sensitive data. + * @param operation - Operation type (get, list, create, update, delete, patch) + * @param resourceType - Resource type (e.g., 'Secret', 'ConfigMap') + * @param namespace - Namespace of the resource + * @param name - Name of the resource (optional for list operations) + * @param context - Cluster context for multi-cluster setups + * + * @example + * // Log access to a Secret + * auditLogSecretAccess('get', 'Secret', 'default', 'my-secret', 'production'); + * // Output: [AUDIT] GET Secret default/my-secret (context: production) at 2024-03-24T... + */ +export function auditLogSecretAccess( + operation: 'get' | 'list' | 'create' | 'update' | 'delete' | 'patch', + resourceType: string, + namespace: string, + name?: string, + context?: string +): void { + const timestamp = new Date().toISOString(); + const resourceId = name ? `${namespace}/${name}` : namespace; + const msg = `[AUDIT] ${operation.toUpperCase()} ${resourceType} ${resourceId} (context: ${normalizeClusterId(context)}) at ${timestamp}`; + + // Use warn level for sensitive resource access to ensure it's logged to files + logger.warn(msg); +} + +/** + * Read a Kubernetes Secret (CoreV1 API) with audit logging. + * @param namespace - Namespace containing the Secret + * @param name - Name of the Secret + * @param context - Optional cluster context + */ +export async function readSecret( + namespace: string, + name: string, + context?: string +): Promise { + // Audit log before attempting read (logs both success and failure) + auditLogSecretAccess('get', 'Secret', namespace, name, context); + try { + const api = await getCoreV1Api(context); + const response = await api.readNamespacedSecret({ namespace, name }); + return response; + } catch (error) { + throw handleK8sError(error, `read Secret ${namespace}/${name}`); + } +} diff --git a/src/lib/server/kubernetes/timeouts.ts b/src/lib/server/kubernetes/timeouts.ts new file mode 100644 index 00000000..42c46f86 --- /dev/null +++ b/src/lib/server/kubernetes/timeouts.ts @@ -0,0 +1,74 @@ +import * as k8s from '@kubernetes/client-node'; + +// --------------------------------------------------------------------------- +// Timeout configuration +// --------------------------------------------------------------------------- + +/** Default timeout for all Kubernetes API requests (30 seconds). */ +export const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Per-operation timeout overrides (ms). Falls back to DEFAULT_TIMEOUT_MS. + * "logs" is higher because log fetching can be slow on large pods. + * "delete" has same timeout as create/update operations. + */ +export const OPERATION_TIMEOUTS: Record = { + list: 30_000, + get: 15_000, + create: 30_000, + update: 30_000, + delete: 30_000, + logs: 60_000 +}; + +/** Returns a PromiseMiddleware that aborts requests exceeding `timeoutMs`. Exported for testing. */ +export function _createTimeoutMiddleware(timeoutMs: number): k8s.Middleware { + return { + pre: async (ctx: k8s.RequestContext) => { + const controller = new AbortController(); + const existingSignal = ctx.getSignal(); + + // Declared before timer so the closure captures the binding correctly. + let timer: ReturnType; + + // If a caller passed an upstream signal (e.g. a request-level AbortController), + // propagate its cancellation: clear our timer and abort our controller. + const onUpstreamAbort = () => { + clearTimeout(timer); + controller.abort(); + }; + if (existingSignal) { + existingSignal.addEventListener('abort', onUpstreamAbort, { once: true }); + } + + timer = setTimeout(() => { + // Timeout fired — remove the upstream listener so it cannot fire later. + if (existingSignal) { + existingSignal.removeEventListener('abort', onUpstreamAbort); + } + controller.abort(); + }, timeoutMs); + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref(); + } + + controller.signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + if (existingSignal) { + existingSignal.removeEventListener('abort', onUpstreamAbort); + } + }, + { once: true } + ); + + ctx.setSignal(controller.signal); + return ctx; + }, + // ResponseContext does not expose the request signal, so timer cleanup on + // successful completion is handled by the setTimeout firing as a no-op once + // the fetch promise has already settled. + post: async (ctx: k8s.ResponseContext) => ctx + }; +} diff --git a/src/lib/server/lifecycle/security-validation.ts b/src/lib/server/lifecycle/security-validation.ts new file mode 100644 index 00000000..e9d052bc --- /dev/null +++ b/src/lib/server/lifecycle/security-validation.ts @@ -0,0 +1,54 @@ +import { logger } from '../logger.js'; +import { + testEncryption as testAuthEncryption, + isUsingDevelopmentKey as isUsingDevAuthKey +} from '../auth/crypto.js'; +import { + testEncryption as testClusterEncryption, + isUsingDevelopmentKey as isUsingDevClusterKey +} from '../clusters/encryption.js'; +import { validateProductionSecurityConfig } from '../security-config.js'; + +export function validateStartupSecurity(isProd: boolean): void { + // Encryption checks + logger.info('Validating encryption'); + try { + // Test Auth Encryption + if (!testAuthEncryption()) { + throw new Error('AUTH encryption check failed'); + } + if (isUsingDevAuthKey()) { + if (isProd) { + throw new Error('AUTH_ENCRYPTION_KEY must be set in production'); + } + logger.warn('Using development key for AUTH_ENCRYPTION_KEY'); + } + + // Test Cluster Encryption + if (!testClusterEncryption()) { + throw new Error('Cluster kubeconfig encryption check failed'); + } + if (isUsingDevClusterKey()) { + if (isProd) { + throw new Error('GYRE_ENCRYPTION_KEY must be set in production'); + } + logger.warn('Using development key for GYRE_ENCRYPTION_KEY'); + } + + logger.info('Encryption validation passed'); + } catch (error) { + logger.error(error, 'Encryption validation failed'); + throw error; + } + + if (isProd) { + logger.info('Validating production security configuration'); + try { + validateProductionSecurityConfig(); + logger.info('Production security configuration validated'); + } catch (error) { + logger.error(error, 'Production security configuration invalid'); + throw error; + } + } +} diff --git a/src/lib/server/lifecycle/shutdown.ts b/src/lib/server/lifecycle/shutdown.ts new file mode 100644 index 00000000..8d675915 --- /dev/null +++ b/src/lib/server/lifecycle/shutdown.ts @@ -0,0 +1,143 @@ +import { logger } from '../logger.js'; +import { closeDb } from '../db/index.js'; +import { stopCleanup } from '../kubernetes/flux/reconciliation-cleanup.js'; +import { stopSessionCleanup } from '../auth/session-cleanup.js'; +import { stopAuditLogCleanup } from '../audit.js'; +import { closeAllEventStreams, setEventBusShuttingDown } from '../events.js'; + +let isShuttingDown = false; +let activeShutdownPromise: Promise | null = null; + +function safeCloseDb(context: string = 'shutdown') { + try { + closeDb(); + logger.info(` ✓ Database connection closed (${context})`); + } catch (error) { + logger.error(error, ` ✗ Error closing database during ${context}:`); + } +} + +/** + * Shutdown Gyre gracefully, awaiting any in-flight cleanup work before exiting. + */ +export async function shutdownGyre(): Promise { + logger.info('\n🛑 Shutting down Gyre background tasks...'); + // Mark event bus as shutting down early to reject new subscriptions before await steps + setEventBusShuttingDown(); + try { + const results = await Promise.allSettled([stopCleanup(), stopAuditLogCleanup()]); + results.forEach((result, index) => { + if (result.status === 'rejected') { + const task = index === 0 ? 'stopCleanup' : 'stopAuditLogCleanup'; + logger.error(result.reason, ` ✗ Error during ${task}:`); + } + }); + try { + stopSessionCleanup(); + } catch (error) { + logger.error(error, ' ✗ Error during stopSessionCleanup:'); + } + await closeAllEventStreams(); + logger.info(' ✓ Cleanup schedulers and SSE connections stopped'); + } catch (error) { + logger.error(error, ' ✗ Error during shutdown:'); + } +} + +// Register shutdown handlers +if (typeof process !== 'undefined') { + let forceExit: NodeJS.Timeout | null = null; + let httpDrainTimeout: NodeJS.Timeout | null = null; + + const handleSignal = async (signal: string) => { + if (isShuttingDown) return; + isShuttingDown = true; + logger.info(`\n🛑 Received ${signal}, starting graceful shutdown...`); + + const isProd = process.env.NODE_ENV === 'production'; + + // Force-exit after 15s if graceful shutdown hangs (5s in dev) + // (K8s terminationGracePeriodSeconds defaults to 30s, so we want to exit before SIGKILL) + // NOTE: Lowering terminationGracePeriodSeconds below 30s in K8s manifests would break this timing. + forceExit = setTimeout( + () => { + logger.error(' ✗ Graceful shutdown took too long, forcing exit (HTTP drain timed out)'); + logger.error(' ✗ Force-exiting: any in-flight DB requests will fail'); + safeCloseDb('force-exit'); + process.exit(1); + }, + isProd ? 15_000 : 5_000 + ); + forceExit.unref(); + + activeShutdownPromise = shutdownGyre(); + await activeShutdownPromise; + + if (forceExit) { + clearTimeout(forceExit); + forceExit = null; + } + + // In development (vite), sveltekit:shutdown is not emitted. + // adapter-node only emits sveltekit:shutdown in production builds. + // We exit immediately after cleanup. + if (process.env.NODE_ENV !== 'production') { + safeCloseDb('development shutdown'); + process.exit(0); + } else { + // Production path: adapter-node will emit sveltekit:shutdown when HTTP drain completes. + // Guard against the event never firing. + httpDrainTimeout = setTimeout(() => { + logger.error(' ✗ sveltekit:shutdown not received within 10s, forcing exit'); + safeCloseDb('drain timeout'); + process.exit(1); + }, 10_000); + httpDrainTimeout.unref(); + } + }; + + process.on('SIGTERM', () => + handleSignal('SIGTERM').catch((err) => logger.error(err, 'Signal handler error:')) + ); + process.on('SIGINT', () => + handleSignal('SIGINT').catch((err) => logger.error(err, 'Signal handler error:')) + ); + + process.on('sveltekit:shutdown', async () => { + logger.info(' ✓ HTTP server stopped'); + + // If sveltekit:shutdown fires without prior signal (adapter handled it), + // run shutdown now to ensure cleanup completes. + // If a signal handler already started shutdown, await the same promise so + // safeCloseDb only runs after that work is fully done (no race). + if (!isShuttingDown) { + isShuttingDown = true; + // Arm the same fail-safe backstop used in handleSignal so a hung + // shutdownGyre() cannot block the process indefinitely. + const isProd = process.env.NODE_ENV === 'production'; + const svkForceExit = setTimeout( + () => { + logger.error(' ✗ Graceful shutdown took too long, forcing exit'); + safeCloseDb('force-exit'); + process.exit(1); + }, + isProd ? 15_000 : 5_000 + ); + svkForceExit.unref(); + activeShutdownPromise = shutdownGyre(); + await activeShutdownPromise; + clearTimeout(svkForceExit); + } else if (activeShutdownPromise) { + await activeShutdownPromise; + } + + // Clear fail-safe timers only after active shutdown finishes so they + // remain in place as a backstop if shutdownGyre() hangs. + if (forceExit) clearTimeout(forceExit); + if (httpDrainTimeout) clearTimeout(httpDrainTimeout); + + safeCloseDb('graceful shutdown'); + logger.info('👋 Gyre shutdown complete.'); + process.exit(0); + }); +} diff --git a/src/lib/server/lifecycle/startup.ts b/src/lib/server/lifecycle/startup.ts new file mode 100644 index 00000000..610180f9 --- /dev/null +++ b/src/lib/server/lifecycle/startup.ts @@ -0,0 +1,177 @@ +import { logger } from '../logger.js'; +import { getDbSync } from '../db/index.js'; +import { createDefaultAdminIfNeeded, setSetupTokenFile } from '../auth.js'; +import { initDatabase } from '../db/migrate.js'; +import { initializeDefaultPolicies, repairUserPolicyBindings } from '../rbac-defaults.js'; +import { quarantineInvalidNamespacePatterns } from '../rbac.js'; +import { writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { migrateKubeconfigs } from '../clusters/migration.js'; +import { seedAuthSettings } from '../settings.js'; +import { seedAuthProviders } from '../auth/seed-providers.js'; +import { scheduleCleanup } from '../kubernetes/flux/reconciliation-cleanup.js'; +import { scheduleSessionCleanup } from '../auth/session-cleanup.js'; +import { scheduleAuditLogCleanup } from '../audit.js'; +import { validateStartupSecurity } from './security-validation.js'; +import { getCurrentNamespace } from '../kubernetes/namespace.js'; + +/** + * Initialize Gyre on startup + * - Creates database tables if they don't exist + * - Creates default admin user if no users exist + * - Logs deployment mode + */ +export async function initializeGyre(): Promise { + logger.info('='.repeat(60)); + logger.info(' Gyre - FluxCD Dashboard'); + logger.info('='.repeat(60)); + + // Log deployment mode + const isInCluster = !!process.env.KUBERNETES_SERVICE_HOST; + const isProd = process.env.NODE_ENV === 'production'; + + if (isInCluster) { + logger.info('📦 Deployment Mode: In-Cluster'); + logger.info(' Using Kubernetes ServiceAccount for API access'); + } else { + logger.info('📦 Deployment Mode: Local Development'); + logger.info(' Using local kubeconfig for cluster access'); + } + + validateStartupSecurity(isProd); + + // Initialize database connection and tables + logger.info('\n🗄️ Initializing database...'); + try { + getDbSync(); + initDatabase(); // Create tables if they don't exist + logger.info(' ✓ Database connection established'); + } catch (error) { + logger.error(error, ' ✗ Failed to connect to database'); + throw error; + } + + // Migrate kubeconfigs to new encryption format if needed (must run after DB is initialized) + try { + const { migrated, failed } = await migrateKubeconfigs(); + if (migrated > 0) { + logger.info(` ✓ Migrated ${migrated} cluster(s) to new encryption format`); + } + if (failed > 0) { + logger.warn(` ⚠️ Failed to migrate ${failed} cluster(s) — check logs above for details`); + } + } catch (error) { + logger.error(error, ' ✗ Failed to migrate kubeconfigs'); + // Don't throw here, as the app can still function with old encryption if migration fails + } + + // Create default admin if needed + logger.info('\n👤 Setting up authentication...'); + try { + let tokenFile: string | null = null; + const { password: setupToken, mode } = await createDefaultAdminIfNeeded({ + persistSetupToken: isInCluster + ? undefined + : (token) => { + tokenFile = join(tmpdir(), `gyre-setup-token-${Date.now()}.txt`); + try { + writeFileSync(tokenFile, token, { mode: 0o600, flag: 'wx' }); + } catch (writeErr) { + logger.error(writeErr, ' ✗ Failed to write setup token file'); + throw writeErr; + } + } + }); + + if (setupToken) { + logger.info(' ⚠️ FIRST TIME SETUP - INITIAL ADMIN PASSWORD:'); + logger.info(' ' + '='.repeat(50)); + logger.info(' Username: admin'); + + if (mode === 'in-cluster') { + // In-cluster mode: show K8s secret command + const namespace = getCurrentNamespace(); + logger.info(' Password has been securely stored in a Kubernetes secret.'); + logger.info(' ' + '='.repeat(50)); + logger.info(' \n 📋 To retrieve the password, run:'); + logger.info( + ` kubectl get secret gyre-initial-admin-secret -n ${namespace} -o jsonpath='{.data.password}' | base64 -d` + ); + logger.info('\n ⚠️ Please change this password after first login!'); + logger.info(' After first login, the secret will be marked as consumed.'); + } else { + if (!tokenFile) { + throw new Error('Setup token file was not created before admin persistence'); + } + // Register the file path so auth.ts can remove it after first login. + setSetupTokenFile(tokenFile); + logger.warn(' ⚠️ WARNING: Container or terminal logs may capture plaintext passwords.'); + logger.warn(' The setup token has been written to a restricted file (mode 0600).'); + logger.info(` Token file: ${tokenFile}`); + logger.info(' ' + '='.repeat(50)); + logger.info('\n 💡 For local development, you can also set ADMIN_PASSWORD env var'); + logger.info(" ⚠️ Please read the token from the file above - it won't be shown again!"); + } + } + logger.info(' ✓ Authentication ready'); + } catch (error) { + logger.error(error, ' ✗ Failed to setup authentication'); + throw error; + } + + // Initialize default RBAC policies + logger.info('\n🔐 Setting up RBAC policies...'); + try { + await initializeDefaultPolicies(); + const repairedCount = await repairUserPolicyBindings(); + if (repairedCount > 0) { + logger.info(` ✓ Repaired RBAC bindings for ${repairedCount} existing user(s)`); + } + const quarantinedCount = await quarantineInvalidNamespacePatterns(); + if (quarantinedCount > 0) { + logger.warn( + ` ⚠ Quarantined ${quarantinedCount} RBAC policy/policies with invalid namespacePattern` + ); + } + logger.info(' ✓ RBAC policies ready'); + } catch (error) { + logger.error(error, ' ✗ Failed to setup RBAC policies'); + throw error; + } + + // Seed auth settings and providers from environment + logger.info('\n🔑 Setting up authentication settings...'); + try { + await seedAuthSettings(); + const seedResult = await seedAuthProviders(); + if (seedResult.created > 0) { + logger.info(` ✓ Seeded ${seedResult.created} auth provider(s)`); + } + if (seedResult.skipped > 0) { + logger.info( + ` ℹ Skipped ${seedResult.skipped} provider(s) (existing or invalid/missing secrets)` + ); + } + logger.info(' ✓ Authentication settings ready'); + } catch (error) { + logger.error(error, ' ✗ Failed to seed auth settings'); + // Don't throw - app can still work without seeded providers + } + + // Schedule reconciliation history cleanup + logger.info('\n🧹 Setting up data cleanup...'); + try { + scheduleCleanup(); + scheduleAuditLogCleanup(); + scheduleSessionCleanup(); + logger.info(' ✓ Cleanup schedulers initialized'); + } catch (error) { + logger.error(error, ' ✗ Failed to schedule cleanup'); + // Don't throw - app can still work without cleanup + } + + logger.info('\n' + '='.repeat(60)); + logger.info(' Gyre is ready!'); + logger.info('='.repeat(60) + '\n'); +} diff --git a/src/lib/server/rbac-defaults.ts b/src/lib/server/rbac-defaults.ts index 9da3b103..1aef0ba0 100644 --- a/src/lib/server/rbac-defaults.ts +++ b/src/lib/server/rbac-defaults.ts @@ -8,6 +8,8 @@ import { getDbSync } from './db/index.js'; import { rbacPolicies, rbacBindings, type User } from './db/schema.js'; import { eq, and } from 'drizzle-orm'; +type Tx = Parameters['transaction']>[0]>[0]; + /** * Default policy IDs (deterministic UUIDs for idempotency) */ @@ -80,12 +82,18 @@ export async function initializeDefaultPolicies(): Promise { * Called when a new user is created */ export async function bindUserToDefaultPolicies(user: User): Promise { + const db = getDbSync(); + db.transaction((tx) => { + bindUserToDefaultPoliciesInTx(tx, user); + }); +} + +export function bindUserToDefaultPoliciesInTx(tx: Tx, user: User): void { // Admin role doesn't need policy bindings (has full access by role) if (user.role === 'admin') { return; } - const db = getDbSync(); const policiesToBind: string[] = []; // Determine which policies to bind based on role @@ -98,15 +106,14 @@ export async function bindUserToDefaultPolicies(user: User): Promise { // Bind policies to user (skip if already bound) for (const policyId of policiesToBind) { - const existing = await db.query.rbacBindings.findFirst({ - where: and(eq(rbacBindings.userId, user.id), eq(rbacBindings.policyId, policyId)) - }); + const existing = tx + .select({ userId: rbacBindings.userId }) + .from(rbacBindings) + .where(and(eq(rbacBindings.userId, user.id), eq(rbacBindings.policyId, policyId))) + .get(); if (!existing) { - await db.insert(rbacBindings).values({ - userId: user.id, - policyId - }); + tx.insert(rbacBindings).values({ userId: user.id, policyId }).run(); } } } @@ -147,21 +154,25 @@ export async function repairUserPolicyBindings(): Promise { * Called when a user's role is changed */ export async function syncUserPolicyBindings(user: User): Promise { + const db = getDbSync(); + db.transaction((tx) => { + syncUserPolicyBindingsInTx(tx, user); + }); + + logger.info(` ✓ Synced RBAC bindings for role: ${user.role}`); +} + +export function syncUserPolicyBindingsInTx(tx: Tx, user: User): void { // Admin doesn't need policy bindings if (user.role === 'admin') { // Remove all bindings for admin users - const db = getDbSync(); - await db.delete(rbacBindings).where(eq(rbacBindings.userId, user.id)); + tx.delete(rbacBindings).where(eq(rbacBindings.userId, user.id)).run(); return; } - const db = getDbSync(); - // Remove all existing bindings first - await db.delete(rbacBindings).where(eq(rbacBindings.userId, user.id)); + tx.delete(rbacBindings).where(eq(rbacBindings.userId, user.id)).run(); // Add correct bindings for current role - await bindUserToDefaultPolicies(user); - - logger.info(` ✓ Synced RBAC bindings for role: ${user.role}`); + bindUserToDefaultPoliciesInTx(tx, user); } diff --git a/src/lib/server/request/access.ts b/src/lib/server/request/access.ts index 9132462d..20f45898 100644 --- a/src/lib/server/request/access.ts +++ b/src/lib/server/request/access.ts @@ -1,6 +1,5 @@ import { ADMIN_ROUTE_PREFIXES } from '$lib/server/config.js'; -import { IN_CLUSTER_ID } from '$lib/clusters/identity.js'; -import { getClusterById } from '$lib/server/clusters.js'; +import { resolveClusterSelectionFromCookie } from '$lib/server/clusters/selection.js'; import { isPublicRoute } from '$lib/isPublicRoute.js'; import type { RequestEvent } from '@sveltejs/kit'; @@ -41,25 +40,7 @@ export function enforceAuthenticationGate( export async function resolveClusterContext( event: Pick ): Promise { - const cluster = event.cookies.get('gyre_cluster'); - if (!cluster) { - event.locals.cluster = IN_CLUSTER_ID; - return; - } - - if (cluster === IN_CLUSTER_ID) { - event.locals.cluster = cluster; - return; - } - - const clusterRecord = await getClusterById(cluster); - if (!clusterRecord || !clusterRecord.isActive) { - event.cookies.delete('gyre_cluster', { path: '/' }); - event.locals.cluster = IN_CLUSTER_ID; - return; - } - - event.locals.cluster = cluster; + event.locals.cluster = await resolveClusterSelectionFromCookie(event.cookies); } export function enforceAdminRouteGate( diff --git a/src/lib/stores/cluster.svelte.ts b/src/lib/stores/cluster.svelte.ts index 79c4336f..78bf7b66 100644 --- a/src/lib/stores/cluster.svelte.ts +++ b/src/lib/stores/cluster.svelte.ts @@ -1,6 +1,5 @@ -import { browser } from '$app/environment'; +import { invalidate } from '$app/navigation'; import { IN_CLUSTER_ID, normalizeClusterId, type ClusterOption } from '$lib/clusters/identity.js'; -import Cookies from 'js-cookie'; /** * Cluster Store using Svelte 5's $state @@ -11,26 +10,37 @@ class ClusterStore { loaded = $state(false); error = $state(null); - constructor() { - // Initialize from cookie if in browser - if (browser) { - const value = Cookies.get('gyre_cluster'); - this.current = normalizeClusterId(value); - } - } + async setCluster(id: string) { + const previousId = this.current; + const requestedId = normalizeClusterId(id); + this.current = requestedId; + this.error = null; - setCluster(id: string) { - const normalizedId = normalizeClusterId(id); - this.current = normalizedId; - if (browser) { - Cookies.set('gyre_cluster', normalizedId, { - expires: 30, - path: '/', - secure: true, - sameSite: 'Lax' + try { + const response = await fetch('/api/v1/user/cluster', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clusterId: requestedId }) }); - // Reload to refresh all data from the new cluster - window.location.reload(); + + if (!response.ok) { + throw new Error('Failed to switch cluster'); + } + + const payload = (await response.json()) as { + currentClusterId?: string; + currentCluster?: ClusterOption; + selectableClusters?: ClusterOption[]; + }; + this.current = normalizeClusterId(payload.currentClusterId ?? payload.currentCluster?.id); + if (payload.selectableClusters) { + this.setAvailable(payload.selectableClusters); + } + await invalidate('gyre:layout'); + } catch (error) { + this.current = previousId; + this.error = error instanceof Error ? error.message : 'Failed to switch cluster'; + throw error; } } diff --git a/src/lib/stores/events.svelte.ts b/src/lib/stores/events.svelte.ts index 74dde347..008514e9 100644 --- a/src/lib/stores/events.svelte.ts +++ b/src/lib/stores/events.svelte.ts @@ -3,6 +3,8 @@ * Falls back to polling if SSE is not available */ +export * from './events/types.js'; + import { IN_CLUSTER_ID, normalizeClusterId } from '$lib/clusters/identity.js'; import { preferences } from './preferences.svelte'; import { clusterStore } from './cluster.svelte'; @@ -14,8 +16,13 @@ import { MAX_NOTIFICATIONS, MESSAGE_PREVIEW_LENGTH } from '$lib/config/constants'; - -export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; +import type { + ConnectionStatus, + EventCallback, + NotificationMessage, + ResourceEvent, + StatusCallback +} from './events/types.js'; interface NotificationState { revision: string | undefined; @@ -24,47 +31,6 @@ interface NotificationState { messagePreview: string; } -export interface ResourceEvent { - type: 'ADDED' | 'MODIFIED' | 'DELETED' | 'CONNECTED' | 'HEARTBEAT' | 'ERROR' | 'SHUTDOWN'; - clusterId?: string; - resourceType?: string; - serverSessionId?: string; - reason?: string; - resource?: { - metadata: { - name: string; - namespace: string; - uid: string; - }; - status?: { - conditions?: Array<{ - type: string; - status: string; - reason?: string; - message?: string; - }>; - }; - }; - message?: string; - timestamp: string; -} - -export interface NotificationMessage { - id: string; - clusterId: string; - type: 'info' | 'success' | 'warning' | 'error'; - title: string; - message: string; - resourceType?: string; - resourceName?: string; - resourceNamespace?: string; - timestamp: Date; - read: boolean; -} - -type EventCallback = (event: ResourceEvent) => void; -type StatusCallback = (status: ConnectionStatus) => void; - function hashStorageUserIdentity(value: string): string { let hash = 0xcbf29ce484222325n; for (const byte of new TextEncoder().encode(value)) { diff --git a/src/lib/stores/events/connection.ts b/src/lib/stores/events/connection.ts new file mode 100644 index 00000000..4a43a2d8 --- /dev/null +++ b/src/lib/stores/events/connection.ts @@ -0,0 +1,20 @@ +import { + MAX_RECONNECT_ATTEMPTS, + RECONNECT_DELAY_MS, + MAX_RECONNECT_DELAY_MS +} from '$lib/config/constants'; + +export const DEFAULT_CONNECTION_OPTIONS = Object.freeze({ + maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + reconnectDelay: RECONNECT_DELAY_MS, + maxReconnectDelay: MAX_RECONNECT_DELAY_MS +}); + +export function getReconnectDelay(attempt: number): number { + const baseDelay = Math.min( + DEFAULT_CONNECTION_OPTIONS.reconnectDelay * Math.pow(2, Math.max(0, attempt)), + DEFAULT_CONNECTION_OPTIONS.maxReconnectDelay + ); + const jitteredDelay = baseDelay / 2 + Math.random() * (baseDelay / 2); + return Math.min(jitteredDelay, DEFAULT_CONNECTION_OPTIONS.maxReconnectDelay); +} diff --git a/src/lib/stores/events/notifications.ts b/src/lib/stores/events/notifications.ts new file mode 100644 index 00000000..31e748d3 --- /dev/null +++ b/src/lib/stores/events/notifications.ts @@ -0,0 +1,26 @@ +import { MESSAGE_PREVIEW_LENGTH } from '$lib/config/constants'; +import type { NotificationMessage, ResourceEvent } from './types.js'; + +export function getRevisionFromResource(resource: ResourceEvent['resource']): string | undefined { + if (!resource) return undefined; + const status = resource.status as Record | undefined; + if (!status) return undefined; + return ( + (status.lastAppliedRevision as string) || + ((status.artifact as Record)?.revision as string) || + (status.lastAttemptedRevision as string) + ); +} + +export function getMessagePreview(message?: string): string { + return message?.substring(0, MESSAGE_PREVIEW_LENGTH) || ''; +} + +export function getNotificationType(event: ResourceEvent): NotificationMessage['type'] { + if (event.type === 'ERROR') return 'error'; + if (event.type === 'DELETED') return 'warning'; + const readyCondition = event.resource?.status?.conditions?.find((c) => c.type === 'Ready'); + if (readyCondition?.status === 'False') return 'warning'; + if (event.type === 'ADDED') return 'success'; + return 'info'; +} diff --git a/src/lib/stores/events/storage.ts b/src/lib/stores/events/storage.ts new file mode 100644 index 00000000..ee4aff7a --- /dev/null +++ b/src/lib/stores/events/storage.ts @@ -0,0 +1,19 @@ +import { normalizeClusterId } from '$lib/clusters/identity.js'; + +export function hashStorageUserIdentity(value: string): string { + let hash = 0xcbf29ce484222325n; + for (const byte of new TextEncoder().encode(value)) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + return hash.toString(16).padStart(16, '0'); +} + +export function getNotificationStorageKeys(clusterId: string, userIdentity: string | null) { + const normalizedClusterId = normalizeClusterId(clusterId); + const userScope = userIdentity ? hashStorageUserIdentity(userIdentity) : 'anonymous'; + return { + notifications: `gyre_notifications_${normalizedClusterId}_${userScope}`, + state: `gyre_notification_state_${normalizedClusterId}_${userScope}` + }; +} diff --git a/src/lib/stores/events/types.ts b/src/lib/stores/events/types.ts new file mode 100644 index 00000000..3f49de8f --- /dev/null +++ b/src/lib/stores/events/types.ts @@ -0,0 +1,49 @@ +export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; + +export interface NotificationState { + revision: string | undefined; + readyStatus: string | undefined; + readyReason: string | undefined; + messagePreview: string; +} + +export interface ResourceEvent { + type: 'ADDED' | 'MODIFIED' | 'DELETED' | 'CONNECTED' | 'HEARTBEAT' | 'ERROR' | 'SHUTDOWN'; + clusterId?: string; + resourceType?: string; + serverSessionId?: string; + reason?: string; + resource?: { + metadata: { + name: string; + namespace: string; + uid: string; + }; + status?: { + conditions?: Array<{ + type: string; + status: string; + reason?: string; + message?: string; + }>; + }; + }; + message?: string; + timestamp: string; +} + +export interface NotificationMessage { + id: string; + clusterId: string; + type: 'info' | 'success' | 'warning' | 'error'; + title: string; + message: string; + resourceType?: string; + resourceName?: string; + resourceNamespace?: string; + timestamp: Date; + read: boolean; +} + +export type EventCallback = (event: ResourceEvent) => void; +export type StatusCallback = (status: ConnectionStatus) => void; diff --git a/src/lib/templates/alert.ts b/src/lib/templates/alert.ts new file mode 100644 index 00000000..2fd36462 --- /dev/null +++ b/src/lib/templates/alert.ts @@ -0,0 +1,193 @@ +import type { ResourceTemplate } from './types.js'; + +export const ALERT_TEMPLATE: ResourceTemplate = { + id: 'alert-base', + name: 'Alert', + description: 'Sends notifications for FluxCD events', + kind: 'Alert', + group: 'notification.toolkit.fluxcd.io', + version: 'v1beta3', + category: 'notifications', + plural: 'alerts', + yaml: `apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Alert +metadata: + name: example + namespace: flux-system +spec: + providerRef: + name: slack + eventSeverity: info + eventSources: + - kind: GitRepository + name: '*' + - kind: Kustomization + name: '*'`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'notification', + title: 'Notification Settings', + description: 'Provider and severity configuration', + defaultExpanded: true + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Event filtering and summary', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-alert', + description: 'Unique name for this Alert resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Notification Settings + { + name: 'providerName', + label: 'Provider Name', + path: 'spec.providerRef.name', + type: 'string', + required: true, + section: 'notification', + placeholder: 'slack', + description: 'Name of the Provider resource to send notifications to', + referenceType: 'Provider' + }, + { + name: 'eventSources', + label: 'Event Sources', + path: 'spec.eventSources', + type: 'array', + required: true, + section: 'notification', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'kind', + label: 'Kind', + path: 'kind', + type: 'string', + required: true, + placeholder: 'GitRepository', + description: 'Resource kind (e.g., GitRepository, Kustomization)' + }, + { + name: 'name', + label: 'Name', + path: 'name', + type: 'string', + required: true, + placeholder: '* or resource name', + description: 'Resource name; use * to watch all resources of that kind', + referenceTypeField: 'kind', + referenceNamespaceField: 'namespace' + } + ], + placeholder: 'GitRepository', + description: + 'Resources to monitor for events. Use * for name to watch all resources of that kind.', + helpText: + 'Define which FluxCD resources to monitor. Each entry needs a kind (e.g., GitRepository, Kustomization) and name (use * for all).', + docsUrl: 'https://fluxcd.io/flux/components/notification/alerts/#event-sources' + }, + { + name: 'eventSeverity', + label: 'Event Severity', + path: 'spec.eventSeverity', + type: 'select', + section: 'notification', + default: 'info', + options: [ + { label: 'Info (all events)', value: 'info' }, + { label: 'Error (only errors)', value: 'error' } + ], + description: 'Minimum severity level to trigger alerts' + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend sending notifications' + }, + { + name: 'summary', + label: 'Summary', + path: 'spec.summary', + type: 'string', + section: 'advanced', + placeholder: 'Production cluster alerts', + description: + 'Optional summary to include in notifications (Deprecated: use Event Metadata instead)' + }, + { + name: 'eventMetadata', + label: 'Event Metadata', + path: 'spec.eventMetadata', + type: 'textarea', + section: 'advanced', + placeholder: 'cluster: prod-1\nenv: production', + description: 'Additional metadata to include in alerts (YAML format)' + }, + { + name: 'inclusionList', + label: 'Inclusion List', + path: 'spec.inclusionList', + type: 'array', + section: 'advanced', + arrayItemType: 'string', + placeholder: 'Succeeded', + description: 'Specific events to include (if empty, all events are included)' + }, + { + name: 'exclusionList', + label: 'Exclusion List', + path: 'spec.exclusionList', + type: 'array', + section: 'advanced', + arrayItemType: 'string', + placeholder: 'Progressing', + description: 'Events to exclude from notifications' + } + ] +}; diff --git a/src/lib/templates/bucket.ts b/src/lib/templates/bucket.ts new file mode 100644 index 00000000..4ae6348a --- /dev/null +++ b/src/lib/templates/bucket.ts @@ -0,0 +1,288 @@ +import type { ResourceTemplate } from './types.js'; + +export const BUCKET_TEMPLATE: ResourceTemplate = { + id: 'bucket-base', + name: 'Bucket', + description: 'Sources from an S3-compatible bucket', + kind: 'Bucket', + group: 'source.toolkit.fluxcd.io', + version: 'v1', + category: 'sources', + plural: 'buckets', + yaml: `apiVersion: source.toolkit.fluxcd.io/v1 +kind: Bucket +metadata: + name: example + namespace: flux-system +spec: + interval: 5m + provider: generic + bucketName: my-bucket + endpoint: s3.amazonaws.com`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'bucket', + title: 'Bucket Configuration', + description: 'S3-compatible bucket settings', + defaultExpanded: true + }, + { + id: 'auth', + title: 'Authentication', + description: 'Credentials and access configuration', + collapsible: true, + defaultExpanded: false + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-bucket', + description: 'Unique name for this Bucket resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Bucket Configuration + { + name: 'provider', + label: 'Provider', + path: 'spec.provider', + type: 'select', + required: true, + section: 'bucket', + default: 'generic', + options: [ + { label: 'Generic S3', value: 'generic' }, + { label: 'AWS S3', value: 'aws' }, + { label: 'Google Cloud Storage', value: 'gcp' }, + { label: 'Azure Blob Storage', value: 'azure' } + ], + description: 'Cloud provider type' + }, + { + name: 'bucketName', + label: 'Bucket Name', + path: 'spec.bucketName', + type: 'string', + required: true, + section: 'bucket', + placeholder: 'my-artifacts', + description: 'Name of the S3 bucket' + }, + { + name: 'prefix', + label: 'Prefix', + path: 'spec.prefix', + type: 'string', + section: 'bucket', + placeholder: 'path/to/artifacts/', + description: 'Object prefix to filter objects in the bucket' + }, + { + name: 'endpoint', + label: 'Endpoint', + path: 'spec.endpoint', + type: 'string', + required: true, + section: 'bucket', + placeholder: 's3.amazonaws.com', + description: 'S3-compatible endpoint URL' + }, + { + name: 'region', + label: 'Region', + path: 'spec.region', + type: 'string', + section: 'bucket', + placeholder: 'us-east-1', + description: 'Bucket region' + }, + { + name: 'sts', + label: 'STS Configuration', + path: 'spec.sts', + type: 'object', + section: 'bucket', + objectFields: [ + { + name: 'provider', + label: 'Provider', + path: 'provider', + type: 'string', + required: true + }, + { + name: 'endpoint', + label: 'Endpoint', + path: 'endpoint', + type: 'string', + required: true + }, + { + name: 'secretRef', + label: 'Secret', + path: 'secretRef', + type: 'object', + objectFields: [ + { + name: 'name', + label: 'Secret Name', + path: 'name', + type: 'string' + } + ] + }, + { + name: 'certSecretRef', + label: 'TLS Secret', + path: 'certSecretRef', + type: 'object', + objectFields: [ + { + name: 'name', + label: 'Secret Name', + path: 'name', + type: 'string' + } + ] + } + ], + description: 'Security Token Service configuration' + }, + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'bucket', + default: '5m', + placeholder: '5m', + description: 'How often to check for changes', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: + 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' + } + }, + + // Authentication + { + name: 'secretRefName', + label: 'Secret Name', + path: 'spec.secretRef.name', + type: 'string', + section: 'auth', + placeholder: 's3-credentials', + description: 'Secret containing access key and secret key' + }, + { + name: 'serviceAccountName', + label: 'Service Account', + path: 'spec.serviceAccountName', + type: 'string', + section: 'auth', + placeholder: 'bucket-puller', + description: 'ServiceAccount for cloud provider authentication' + }, + { + name: 'certSecretRef', + label: 'TLS Secret', + path: 'spec.certSecretRef.name', + type: 'string', + section: 'auth', + placeholder: 'bucket-tls-certs', + description: 'Secret containing CA/cert/key for TLS authentication (Generic provider only)' + }, + { + name: 'proxySecretRef', + label: 'Proxy Secret', + path: 'spec.proxySecretRef.name', + type: 'string', + section: 'auth', + placeholder: 'proxy-credentials', + description: 'Secret containing proxy credentials' + }, + { + name: 'insecure', + label: 'Insecure', + path: 'spec.insecure', + type: 'boolean', + section: 'auth', + default: false, + description: 'Allow insecure connections (skip TLS verification)' + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend reconciliation' + }, + { + name: 'timeout', + label: 'Timeout', + path: 'spec.timeout', + type: 'duration', + section: 'advanced', + default: '10m', + placeholder: '60s', + description: 'Timeout for bucket operations', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' + } + }, + { + name: 'ignore', + label: 'Ignore Paths', + path: 'spec.ignore', + type: 'textarea', + section: 'advanced', + placeholder: '# .gitignore format\n*.txt', + description: 'Paths to ignore when calculating artifact checksum' + } + ] +}; diff --git a/src/lib/templates/git-repository.ts b/src/lib/templates/git-repository.ts new file mode 100644 index 00000000..2eb65596 --- /dev/null +++ b/src/lib/templates/git-repository.ts @@ -0,0 +1,362 @@ +import { DURATION_VALIDATION, type ResourceTemplate } from './types.js'; + +export const GIT_REPOSITORY_TEMPLATE: ResourceTemplate = { + id: 'git-repository-base', + name: 'Git Repository', + description: 'Sources from a Git repository', + kind: 'GitRepository', + group: 'source.toolkit.fluxcd.io', + version: 'v1', + category: 'sources', + plural: 'gitrepositories', + yaml: `apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: example + namespace: flux-system +spec: + interval: 1m + url: https://github.com/org/repo + ref: + branch: main`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'source', + title: 'Source Configuration', + description: 'Git repository source settings', + defaultExpanded: true + }, + { + id: 'auth', + title: 'Authentication', + description: 'Credentials and access control', + collapsible: true, + defaultExpanded: false + }, + { + id: 'verification', + title: 'Verification', + description: 'GPG signature verification settings', + collapsible: true, + defaultExpanded: false + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-repository', + description: 'Unique name for this GitRepository resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Source Configuration + { + name: 'url', + label: 'Repository URL', + path: 'spec.url', + type: 'string', + required: true, + section: 'source', + placeholder: 'https://github.com/fluxcd/flux2', + description: 'Git repository URL (https://, ssh://, or git@)', + helpText: + 'The Git repository URL to sync from. Supports HTTPS (with optional basic auth), SSH (requires secretRef), and GitHub App authentication.', + docsUrl: 'https://fluxcd.io/flux/components/source/gitrepositories/#url', + validation: { + pattern: '^(https?://|ssh://|git@)', + message: 'URL must start with https://, http://, ssh://, or git@' + } + }, + { + name: 'provider', + label: 'Git Provider', + path: 'spec.provider', + type: 'select', + section: 'source', + default: 'generic', + options: [ + { label: 'Generic Git', value: 'generic' }, + { label: 'GitHub', value: 'github' }, + { label: 'Azure DevOps', value: 'azure' } + ], + description: 'Git provider optimization' + }, + { + name: 'refType', + label: 'Reference Type', + path: 'spec.ref.type', + type: 'select', + section: 'source', + default: 'branch', + virtual: true, + options: [ + { label: 'Branch', value: 'branch' }, + { label: 'Tag', value: 'tag' }, + { label: 'Semver', value: 'semver' }, + { label: 'Commit', value: 'commit' } + ], + description: 'Type of Git reference to track' + }, + { + name: 'branch', + label: 'Branch', + path: 'spec.ref.branch', + type: 'string', + required: true, + section: 'source', + default: 'main', + placeholder: 'main', + description: 'Branch name to track', + showIf: { + field: 'refType', + value: 'branch' + } + }, + { + name: 'tag', + label: 'Tag', + path: 'spec.ref.tag', + type: 'string', + required: true, + section: 'source', + placeholder: 'v1.0.0', + description: 'Tag name to track', + showIf: { + field: 'refType', + value: 'tag' + } + }, + { + name: 'semver', + label: 'Semver Range', + path: 'spec.ref.semver', + type: 'string', + required: true, + section: 'source', + placeholder: '>=1.0.0', + description: 'Semver range to track', + showIf: { + field: 'refType', + value: 'semver' + }, + helpText: + 'Flux supports Masterminds semver constraints, including combined ranges, OR ranges, hyphen ranges, and wildcards.' + }, + { + name: 'commit', + label: 'Commit SHA', + path: 'spec.ref.commit', + type: 'string', + required: true, + section: 'source', + placeholder: 'abc123...', + description: 'Specific commit SHA to track', + showIf: { + field: 'refType', + value: 'commit' + } + }, + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'source', + default: '1m', + placeholder: '1m', + description: 'How often to check for repository changes (e.g., 1m, 1m30s, 1h30m)', + helpText: + 'The interval at which to check the upstream repository for changes. Flux supports: 1h30m, 5m, 30s, etc.', + docsUrl: 'https://fluxcd.io/flux/components/source/gitrepositories/#interval', + validation: DURATION_VALIDATION + }, + + // Authentication + { + name: 'secretRefName', + label: 'Secret Name', + path: 'spec.secretRef.name', + type: 'string', + section: 'auth', + placeholder: 'git-credentials', + description: 'Name of secret containing authentication credentials' + }, + { + name: 'serviceAccountName', + label: 'Service Account', + path: 'spec.serviceAccountName', + type: 'string', + section: 'auth', + showIf: { + field: 'provider', + value: 'azure' + }, + placeholder: 'git-controller', + description: + 'Azure-only: set spec.serviceAccountName when using Azure DevOps with Workload Identity' + }, + { + name: 'proxySecretRef', + label: 'Proxy Secret', + path: 'spec.proxySecretRef.name', + type: 'string', + section: 'auth', + placeholder: 'proxy-credentials', + description: 'Secret containing proxy credentials' + }, + + // Verification + { + name: 'verifyMode', + label: 'Verification Mode', + path: 'spec.verify.mode', + type: 'select', + section: 'verification', + default: '', + options: [ + { label: 'Disabled', value: '' }, + { label: 'Head (branch)', value: 'HEAD' }, + { label: 'Tag', value: 'Tag' }, + { label: 'Tag and Head', value: 'TagAndHEAD' } + ], + description: 'Which references to verify with GPG' + }, + { + name: 'verifySecret', + label: 'Verification Secret', + path: 'spec.verify.secretRef.name', + type: 'string', + required: true, + section: 'verification', + placeholder: 'git-pgp-public-keys', + description: 'Secret containing GPG public keys for verification', + showIf: { + field: 'verifyMode', + value: ['HEAD', 'Tag', 'TagAndHEAD'] + } + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend reconciliation of this repository' + }, + { + name: 'timeout', + label: 'Timeout', + path: 'spec.timeout', + type: 'duration', + section: 'advanced', + default: '10m', + placeholder: '60s', + description: 'Timeout for Git operations', + validation: DURATION_VALIDATION + }, + { + name: 'recurseSubmodules', + label: 'Recurse Submodules', + path: 'spec.recurseSubmodules', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Recursively clone Git submodules' + }, + { + name: 'sparseCheckout', + label: 'Sparse Checkout', + path: 'spec.sparseCheckout', + type: 'array', + section: 'advanced', + arrayItemType: 'string', + placeholder: './dir1', + description: 'List of directories to checkout' + }, + { + name: 'ignore', + label: 'Ignore Paths', + path: 'spec.ignore', + type: 'textarea', + section: 'advanced', + placeholder: '# .gitignore format\n*.txt\n/temp/', + description: 'Paths to ignore when calculating artifact checksum (.gitignore format)' + }, + { + name: 'include', + label: 'Include Repositories', + path: 'spec.include', + type: 'array', + section: 'advanced', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'repository', + label: 'Repository', + path: 'repository.name', + type: 'string', + required: true, + placeholder: 'other-repo', + referenceType: 'GitRepository' + }, + { + name: 'toPath', + label: 'To Path', + path: 'toPath', + type: 'string', + placeholder: './included' + }, + { + name: 'fromPath', + label: 'From Path', + path: 'fromPath', + type: 'string', + placeholder: './' + } + ], + description: 'Additional Git repositories to include' + } + ] +}; diff --git a/src/lib/templates/helm-chart.ts b/src/lib/templates/helm-chart.ts new file mode 100644 index 00000000..0d7c6ea3 --- /dev/null +++ b/src/lib/templates/helm-chart.ts @@ -0,0 +1,185 @@ +import type { ResourceTemplate } from './types.js'; + +export const HELM_CHART_TEMPLATE: ResourceTemplate = { + id: 'helm-chart-base', + name: 'Helm Chart', + description: 'References a Helm chart from a repository', + kind: 'HelmChart', + group: 'source.toolkit.fluxcd.io', + version: 'v1', + category: 'sources', + plural: 'helmcharts', + yaml: `apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: example + namespace: flux-system +spec: + interval: 5m + chart: podinfo + version: ">=1.0.0" + sourceRef: + kind: HelmRepository + name: podinfo`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'chart', + title: 'Chart Configuration', + description: 'Chart source and version', + defaultExpanded: true + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-chart', + description: 'Unique name for this HelmChart resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Chart Configuration + { + name: 'sourceKind', + label: 'Source Kind', + path: 'spec.sourceRef.kind', + type: 'select', + required: true, + section: 'chart', + default: 'HelmRepository', + options: [ + { label: 'HelmRepository', value: 'HelmRepository' }, + { label: 'GitRepository', value: 'GitRepository' }, + { label: 'Bucket', value: 'Bucket' } + ], + description: 'Type of source containing the chart' + }, + { + name: 'sourceName', + label: 'Source Name', + path: 'spec.sourceRef.name', + type: 'string', + required: true, + section: 'chart', + placeholder: 'podinfo', + description: 'Name of the source resource', + referenceTypeField: 'sourceKind' + }, + { + name: 'chart', + label: 'Chart Name', + path: 'spec.chart', + type: 'string', + required: true, + section: 'chart', + placeholder: 'podinfo', + description: 'Name of the Helm chart' + }, + { + name: 'version', + label: 'Chart Version', + path: 'spec.version', + type: 'string', + section: 'chart', + default: '*', + placeholder: '>=1.0.0', + description: 'SemVer version constraint' + }, + { + name: 'reconcileStrategy', + label: 'Reconcile Strategy', + path: 'spec.reconcileStrategy', + type: 'select', + section: 'chart', + default: 'ChartVersion', + options: [ + { label: 'Chart Version', value: 'ChartVersion' }, + { label: 'Revision', value: 'Revision' } + ], + description: 'What enables the creation of a new artifact' + }, + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'chart', + default: '5m', + placeholder: '5m', + description: 'How often to check for new chart versions', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: + 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' + } + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend reconciliation' + }, + { + name: 'valuesFiles', + label: 'Values Files', + path: 'spec.valuesFiles', + type: 'array', + section: 'advanced', + arrayItemType: 'string', + placeholder: 'values.yaml', + description: 'List of values files to merge' + }, + { + name: 'ignoreMissingValuesFiles', + label: 'Ignore Missing Values Files', + path: 'spec.ignoreMissingValuesFiles', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Silently ignore missing values files rather than failing' + } + ] +}; diff --git a/src/lib/templates/helm-release.ts b/src/lib/templates/helm-release.ts new file mode 100644 index 00000000..c42be96e --- /dev/null +++ b/src/lib/templates/helm-release.ts @@ -0,0 +1,535 @@ +import { DURATION_VALIDATION, type ResourceTemplate } from './types.js'; + +export const HELM_RELEASE_TEMPLATE: ResourceTemplate = { + id: 'helm-release-base', + name: 'Helm Release', + description: 'Deploys a Helm chart', + kind: 'HelmRelease', + group: 'helm.toolkit.fluxcd.io', + version: 'v2', + category: 'deployments', + plural: 'helmreleases', + yaml: `apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: example + namespace: flux-system +spec: + interval: 5m + chart: + spec: + chart: podinfo + version: ">=1.0.0" + sourceRef: + kind: HelmRepository + name: bitnami + namespace: flux-system`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'chart', + title: 'Chart Configuration', + description: 'Helm chart source and version', + defaultExpanded: true + }, + { + id: 'release', + title: 'Release Settings', + description: 'Helm release configuration', + defaultExpanded: true + }, + { + id: 'upgrade', + title: 'Upgrade & Rollback', + description: 'Upgrade and rollback behavior', + collapsible: true, + defaultExpanded: false + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + }, + { + id: 'drift', + title: 'Drift Detection', + description: 'Drift detection and correction', + collapsible: true, + defaultExpanded: false + }, + { + id: 'resourceLimits', + title: 'Resource Limits', + description: 'CPU and memory constraints for deployed workloads', + collapsible: true, + defaultExpanded: false + }, + { + id: 'install', + title: 'Install Options', + description: 'Helm install action configuration', + collapsible: true, + defaultExpanded: false + }, + { + id: 'test', + title: 'Helm Test', + description: 'Helm test action configuration', + collapsible: true, + defaultExpanded: false + }, + { + id: 'uninstall', + title: 'Uninstall Options', + description: 'Helm uninstall action configuration', + collapsible: true, + defaultExpanded: false + }, + { + id: 'remote', + title: 'Remote Cluster', + description: 'KubeConfig for remote cluster', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-release', + description: 'Unique name for this HelmRelease resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Chart Configuration + { + name: 'chartSourceKind', + label: 'Chart Source Kind', + path: 'spec.chart.spec.sourceRef.kind', + type: 'select', + required: true, + section: 'chart', + default: 'HelmRepository', + options: [ + { label: 'HelmRepository', value: 'HelmRepository' }, + { label: 'GitRepository', value: 'GitRepository' }, + { label: 'Bucket', value: 'Bucket' } + ], + description: 'Type of source containing the chart' + }, + { + name: 'chartSourceName', + label: 'Chart Source Name', + path: 'spec.chart.spec.sourceRef.name', + type: 'string', + required: true, + section: 'chart', + placeholder: 'bitnami', + description: 'Name of the source resource', + referenceTypeField: 'chartSourceKind', + referenceNamespaceField: 'chartSourceNamespace' + }, + { + name: 'chartSourceNamespace', + label: 'Chart Source Namespace', + path: 'spec.chart.spec.sourceRef.namespace', + type: 'string', + section: 'chart', + placeholder: 'flux-system', + description: 'Namespace of the chart source' + }, + { + name: 'chartName', + label: 'Chart Name', + path: 'spec.chart.spec.chart', + type: 'string', + required: true, + section: 'chart', + placeholder: 'podinfo', + description: 'Name of the Helm chart' + }, + { + name: 'chartVersion', + label: 'Chart Version', + path: 'spec.chart.spec.version', + type: 'string', + section: 'chart', + default: '*', + placeholder: '>=1.0.0 <2.0.0', + description: 'SemVer version constraint or specific version' + }, + + // Release Settings + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'release', + default: '5m', + placeholder: '5m', + description: 'How often to reconcile the release', + validation: DURATION_VALIDATION + }, + { + name: 'targetNamespace', + label: 'Target Namespace', + path: 'spec.targetNamespace', + type: 'string', + section: 'release', + placeholder: 'default', + description: 'Namespace to install the release into' + }, + { + name: 'storageNamespace', + label: 'Storage Namespace', + path: 'spec.storageNamespace', + type: 'string', + section: 'release', + placeholder: 'flux-system', + description: 'Namespace where Helm stores release state' + }, + { + name: 'releaseName', + label: 'Release Name', + path: 'spec.releaseName', + type: 'string', + section: 'release', + placeholder: 'my-app', + description: 'Helm release name (defaults to metadata.name)' + }, + { + name: 'values', + label: 'Values', + path: 'spec.values', + type: 'textarea', + section: 'release', + placeholder: 'replicaCount: 3\nimage:\n tag: v1.0.0', + description: + "Helm values to override (YAML format). Values are passed directly to the chart — ensure they match the chart's values schema.", + helpText: + 'Do not include a top-level resources key here when using the structured resource fields below.' + }, + { + name: 'valuesFiles', + label: 'Values Files', + path: 'spec.chart.spec.valuesFiles', + type: 'array', + section: 'release', + arrayItemType: 'string', + placeholder: 'values.yaml', + description: 'List of values files to use from the chart' + }, + { + name: 'valuesFrom', + label: 'Values From', + path: 'spec.valuesFrom', + type: 'array', + section: 'release', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'kind', + label: 'Kind', + path: 'kind', + type: 'select', + options: [ + { label: 'ConfigMap', value: 'ConfigMap' }, + { label: 'Secret', value: 'Secret' } + ] + }, + { + name: 'name', + label: 'Name', + path: 'name', + type: 'string' + }, + { + name: 'namespace', + label: 'Namespace', + path: 'namespace', + type: 'string', + placeholder: 'flux-system', + description: + 'Namespace of the referenced resource. Leave blank to use the HelmRelease namespace.' + }, + { name: 'valuesKey', label: 'Values Key', path: 'valuesKey', type: 'string' }, + { name: 'targetPath', label: 'Target Path', path: 'targetPath', type: 'string' }, + { name: 'optional', label: 'Optional', path: 'optional', type: 'boolean' } + ], + description: 'References to ConfigMaps or Secrets containing Helm values.', + helpText: + "Security: valuesFrom can reference resources from any namespace if the controller's RBAC permits it. Prefer referencing Secrets and ConfigMaps in the same namespace as the HelmRelease to limit exposure." + }, + { + name: 'dependsOn', + label: 'Dependencies', + path: 'spec.dependsOn', + type: 'array', + section: 'release', + arrayItemType: 'object', + arrayItemFields: [ + { name: 'name', label: 'Name', path: 'name', type: 'string' }, + { name: 'namespace', label: 'Namespace', path: 'namespace', type: 'string' } + ], + description: 'List of HelmReleases this depends on' + }, + { + name: 'commonMetadataLabels', + label: 'Common Labels', + path: 'spec.commonMetadata.labels', + type: 'textarea', + section: 'release', + placeholder: 'app: my-app', + description: + 'Common labels applied to all managed resources (YAML format). Keys max 63 chars, values max 63 chars. Valid chars: alphanumeric, hyphens, underscores, dots.' + }, + { + name: 'commonMetadataAnnotations', + label: 'Common Annotations', + path: 'spec.commonMetadata.annotations', + type: 'textarea', + section: 'release', + placeholder: 'team: frontend', + description: 'Annotations to apply to all resources' + }, + { + name: 'resourceLimitsCpu', + label: 'CPU Limit', + path: 'spec.values.resources.limits.cpu', + type: 'string', + section: 'resourceLimits', + placeholder: '500m', + description: + 'Maximum CPU for deployed pods (e.g. 500m, 1). Sets spec.values.resources.limits.cpu.', + helpText: + 'Most Helm charts expose resources.limits.cpu in their values. Remove resources from the Values field before using this structured field.' + }, + { + name: 'resourceLimitsMemory', + label: 'Memory Limit', + path: 'spec.values.resources.limits.memory', + type: 'string', + section: 'resourceLimits', + placeholder: '128Mi', + description: + 'Maximum memory for deployed pods (e.g. 128Mi, 1Gi). Sets spec.values.resources.limits.memory.', + helpText: 'Remove resources from the Values field before using this structured field.' + }, + { + name: 'resourceRequestsCpu', + label: 'CPU Request', + path: 'spec.values.resources.requests.cpu', + type: 'string', + section: 'resourceLimits', + placeholder: '100m', + description: + 'Requested CPU for scheduling (e.g. 100m). Sets spec.values.resources.requests.cpu.', + helpText: 'Remove resources from the Values field before using this structured field.' + }, + { + name: 'resourceRequestsMemory', + label: 'Memory Request', + path: 'spec.values.resources.requests.memory', + type: 'string', + section: 'resourceLimits', + placeholder: '64Mi', + description: + 'Requested memory for scheduling (e.g. 64Mi). Sets spec.values.resources.requests.memory.', + helpText: 'Remove resources from the Values field before using this structured field.' + }, + + // Upgrade & Rollback + { + name: 'upgradeForce', + label: 'Force Upgrade', + path: 'spec.upgrade.force', + type: 'boolean', + section: 'upgrade', + default: false, + description: 'Force resource updates through delete/recreate' + }, + { + name: 'upgradeCleanupOnFail', + label: 'Cleanup on Fail', + path: 'spec.upgrade.cleanupOnFail', + type: 'boolean', + section: 'upgrade', + default: true, + description: 'Delete resources created during failed upgrade' + }, + { + name: 'rollbackCleanupOnFail', + label: 'Cleanup on Rollback Fail', + path: 'spec.rollback.cleanupOnFail', + type: 'boolean', + section: 'upgrade', + default: true, + description: 'Delete resources created during failed rollback' + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend reconciliation' + }, + { + name: 'timeout', + label: 'Timeout', + path: 'spec.timeout', + type: 'duration', + section: 'advanced', + default: '10m', + placeholder: '5m', + description: 'Timeout for Helm operations', + validation: DURATION_VALIDATION + }, + { + name: 'serviceAccountName', + label: 'Service Account', + path: 'spec.serviceAccountName', + type: 'string', + section: 'advanced', + placeholder: 'helm-controller', + description: 'ServiceAccount to impersonate for Helm operations' + }, + { + name: 'persistentClient', + label: 'Persistent Client', + path: 'spec.persistentClient', + type: 'boolean', + section: 'advanced', + default: true, + description: 'Use a persistent Kubernetes client for this release' + }, + { + name: 'maxHistory', + label: 'Max History', + path: 'spec.maxHistory', + type: 'number', + section: 'advanced', + default: 5, + description: 'Max number of release revisions to keep' + }, + { + name: 'driftMode', + label: 'Drift Detection Mode', + path: 'spec.driftDetection.mode', + type: 'select', + section: 'drift', + default: 'warn', + options: [ + { label: 'Disabled', value: 'disabled' }, + { label: 'Warn', value: 'warn' }, + { label: 'Enabled (Automatic Correction)', value: 'enabled' } + ], + description: 'Mode for drift detection and correction' + }, + { + name: 'installCRDs', + label: 'Install CRDs', + path: 'spec.install.crds', + type: 'select', + section: 'install', + default: 'Create', + options: [ + { label: 'Skip', value: 'Skip' }, + { label: 'Create', value: 'Create' }, + { label: 'CreateReplace', value: 'CreateReplace' } + ] + }, + { + name: 'createNamespace', + label: 'Create Namespace', + path: 'spec.install.createNamespace', + type: 'boolean', + section: 'install', + default: false, + description: 'Create target namespace if it does not exist' + }, + { + name: 'testEnabled', + label: 'Enable Helm Test', + path: 'spec.test.enable', + type: 'boolean', + section: 'test', + default: false, + description: 'Run helm test after install/upgrade' + }, + { + name: 'uninstallKeepHistory', + label: 'Keep History on Uninstall', + path: 'spec.uninstall.keepHistory', + type: 'boolean', + section: 'uninstall', + default: false, + description: 'Retain release history after uninstall' + }, + { + name: 'kubeConfigSecret', + label: 'Remote KubeConfig Secret', + path: 'spec.kubeConfig.secretRef.name', + type: 'string', + section: 'remote', + placeholder: 'remote-cluster-kubeconfig', + description: 'Secret containing KubeConfig for remote cluster' + }, + { + name: 'postRenderers', + label: 'Post Renderers', + path: 'spec.postRenderers', + type: 'array', + section: 'advanced', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'kustomize', + label: 'Kustomize Config', + path: 'kustomize', + type: 'textarea', + placeholder: 'patches:\n - target:\n group: apps' + } + ], + description: + 'Post-renderers to apply to rendered manifests. Only trusted YAML should be used here as patches are applied with cluster write permissions.' + } + ] +}; diff --git a/src/lib/templates/helm-repository.ts b/src/lib/templates/helm-repository.ts new file mode 100644 index 00000000..aad6d3c1 --- /dev/null +++ b/src/lib/templates/helm-repository.ts @@ -0,0 +1,249 @@ +import type { ResourceTemplate } from './types.js'; + +export const HELM_REPOSITORY_TEMPLATE: ResourceTemplate = { + id: 'helm-repository-base', + name: 'Helm Repository', + description: 'Sources from a Helm chart repository', + kind: 'HelmRepository', + group: 'source.toolkit.fluxcd.io', + version: 'v1', + category: 'sources', + plural: 'helmrepositories', + yaml: `apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: example + namespace: flux-system +spec: + interval: 5m + url: https://charts.bitnami.com/bitnami`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'source', + title: 'Repository Configuration', + description: 'Helm repository settings', + defaultExpanded: true + }, + { + id: 'auth', + title: 'Authentication', + description: 'Credentials and TLS configuration', + collapsible: true, + defaultExpanded: false + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-helm-repo', + description: 'Unique name for this HelmRepository resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Repository Configuration + { + name: 'type', + label: 'Repository Type', + path: 'spec.type', + type: 'select', + section: 'source', + default: 'default', + options: [ + { label: 'Default (HTTP/S)', value: 'default' }, + { label: 'OCI', value: 'oci' } + ], + description: 'Type of Helm repository' + }, + { + name: 'provider', + label: 'Provider', + path: 'spec.provider', + type: 'select', + section: 'source', + default: 'generic', + options: [ + { label: 'Generic', value: 'generic' }, + { label: 'AWS', value: 'aws' }, + { label: 'Azure', value: 'azure' }, + { label: 'GCP', value: 'gcp' } + ], + description: 'Cloud provider for OCI repository', + showIf: { + field: 'type', + value: 'oci' + } + }, + { + name: 'url', + label: 'Repository URL', + path: 'spec.url', + type: 'string', + required: true, + section: 'source', + placeholder: 'https://charts.bitnami.com/bitnami', + description: 'HTTP/S Helm repository URL', + validation: { + pattern: '^https?://', + message: 'URL must start with https:// or http://' + }, + showIf: { + field: 'type', + value: 'default' + } + }, + { + name: 'url_oci', + label: 'Repository URL', + path: 'spec.url', + type: 'string', + required: true, + section: 'source', + placeholder: 'oci://ghcr.io/org/charts', + description: 'OCI registry URL (must start with oci://)', + validation: { + pattern: '^oci://', + message: 'OCI repository URL must start with oci://' + }, + showIf: { + field: 'type', + value: 'oci' + } + }, + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'source', + default: '5m', + placeholder: '5m', + description: 'How often to check for new chart versions', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: + 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' + } + }, + + // Authentication + { + name: 'secretRefName', + label: 'Secret Name', + path: 'spec.secretRef.name', + type: 'string', + section: 'auth', + placeholder: 'helm-repo-credentials', + description: + 'Secret containing authentication credentials (username/password or certFile/keyFile)' + }, + { + name: 'passCredentials', + label: 'Pass Credentials', + path: 'spec.passCredentials', + type: 'boolean', + section: 'auth', + default: false, + description: 'Pass credentials to all domains' + }, + { + name: 'insecure', + label: 'Insecure', + path: 'spec.insecure', + type: 'boolean', + section: 'auth', + default: false, + description: 'Allow insecure connections (skip TLS verification)' + }, + { + name: 'certSecretRef', + label: 'TLS Secret', + path: 'spec.certSecretRef.name', + type: 'string', + section: 'auth', + placeholder: 'helm-tls-certs', + description: 'Secret containing CA/cert/key for TLS authentication' + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend reconciliation of this repository' + }, + { + name: 'accessFrom', + label: 'Access From', + path: 'spec.accessFrom.namespaceSelectors', + type: 'array', + section: 'advanced', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'matchLabels', + label: 'Match Labels', + path: 'matchLabels', + type: 'textarea', + placeholder: 'role: frontend' + } + ], + description: 'Cross-namespace access control' + }, + { + name: 'timeout', + label: 'Timeout', + path: 'spec.timeout', + type: 'duration', + section: 'advanced', + default: '10m', + placeholder: '60s', + description: 'Timeout for index download operations', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' + } + } + ] +}; diff --git a/src/lib/templates/image-policy.ts b/src/lib/templates/image-policy.ts new file mode 100644 index 00000000..ba7040b8 --- /dev/null +++ b/src/lib/templates/image-policy.ts @@ -0,0 +1,128 @@ +import type { ResourceTemplate } from './types.js'; + +export const IMAGE_POLICY_TEMPLATE: ResourceTemplate = { + id: 'image-policy-base', + name: 'Image Policy', + description: 'Defines policies for selecting image versions', + kind: 'ImagePolicy', + group: 'image.toolkit.fluxcd.io', + version: 'v1', + category: 'image-automation', + plural: 'imagepolicies', + yaml: `apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImagePolicy +metadata: + name: example + namespace: flux-system +spec: + imageRepositoryRef: + name: example + policy: + semver: + range: ">=1.0.0"`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'policy', + title: 'Policy Configuration', + description: 'Rules for selecting images', + defaultExpanded: true + } + ], + fields: [ + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-policy', + description: 'Unique name for this ImagePolicy resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'imageRepoName', + label: 'Image Repository', + path: 'spec.imageRepositoryRef.name', + type: 'string', + required: true, + section: 'policy', + placeholder: 'my-app', + referenceType: 'ImageRepository', + description: 'ImageRepository to monitor' + }, + { + name: 'policyType', + label: 'Policy Type', + path: 'spec.policy.type', + type: 'select', + section: 'policy', + default: 'semver', + virtual: true, + options: [ + { label: 'SemVer', value: 'semver' }, + { label: 'Numerical', value: 'numerical' }, + { label: 'Alphabetical', value: 'alphabetical' } + ] + }, + { + name: 'semverRange', + label: 'Semver Range', + path: 'spec.policy.semver.range', + type: 'string', + section: 'policy', + default: '>=1.0.0', + showIf: { field: 'policyType', value: 'semver' } + }, + { + name: 'numericalOrder', + label: 'Order', + path: 'spec.policy.numerical.order', + type: 'select', + section: 'policy', + default: 'asc', + options: [ + { label: 'Ascending', value: 'asc' }, + { label: 'Descending', value: 'desc' } + ], + showIf: { field: 'policyType', value: 'numerical' } + }, + { + name: 'alphabeticalOrder', + label: 'Order', + path: 'spec.policy.alphabetical.order', + type: 'select', + section: 'policy', + default: 'asc', + options: [ + { label: 'Ascending', value: 'asc' }, + { label: 'Descending', value: 'desc' } + ], + showIf: { field: 'policyType', value: 'alphabetical' } + } + ] +}; diff --git a/src/lib/templates/image-repository.ts b/src/lib/templates/image-repository.ts new file mode 100644 index 00000000..41b9d03f --- /dev/null +++ b/src/lib/templates/image-repository.ts @@ -0,0 +1,119 @@ +import type { ResourceTemplate } from './types.js'; + +export const IMAGE_REPOSITORY_TEMPLATE: ResourceTemplate = { + id: 'image-repository-base', + name: 'Image Repository', + description: 'Scans container image repositories', + kind: 'ImageRepository', + group: 'image.toolkit.fluxcd.io', + version: 'v1beta2', + category: 'image-automation', + plural: 'imagerepositories', + yaml: `apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageRepository +metadata: + name: example + namespace: flux-system +spec: + interval: 5m + image: ghcr.io/org/app`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'repository', + title: 'Repository Settings', + description: 'Container registry and scan configuration', + defaultExpanded: true + }, + { + id: 'auth', + title: 'Authentication', + description: 'Registry credentials', + collapsible: true, + defaultExpanded: false + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-app', + description: 'Unique name for this ImageRepository resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'image', + label: 'Image', + path: 'spec.image', + type: 'string', + required: true, + section: 'repository', + placeholder: 'ghcr.io/org/app', + description: 'Container image repository to scan' + }, + { + name: 'provider', + label: 'Registry Provider', + path: 'spec.provider', + type: 'select', + section: 'repository', + default: 'generic', + options: [ + { label: 'Generic', value: 'generic' }, + { label: 'AWS', value: 'aws' }, + { label: 'Azure', value: 'azure' }, + { label: 'GCP', value: 'gcp' } + ], + description: 'Cloud provider for registry authentication' + }, + { + name: 'interval', + label: 'Scan Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'repository', + default: '5m', + description: 'How often to scan for new images', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: + 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' + } + } + ] +}; diff --git a/src/lib/templates/image-update-automation.ts b/src/lib/templates/image-update-automation.ts new file mode 100644 index 00000000..100fe284 --- /dev/null +++ b/src/lib/templates/image-update-automation.ts @@ -0,0 +1,108 @@ +import type { ResourceTemplate } from './types.js'; + +export const IMAGE_UPDATE_AUTOMATION_TEMPLATE: ResourceTemplate = { + id: 'image-update-automation-base', + name: 'Image Update Automation', + description: 'Automates image updates to Git', + kind: 'ImageUpdateAutomation', + group: 'image.toolkit.fluxcd.io', + version: 'v1beta2', + category: 'image-automation', + plural: 'imageupdateautomations', + yaml: `apiVersion: image.toolkit.fluxcd.io/v1beta2 +kind: ImageUpdateAutomation +metadata: + name: example + namespace: flux-system +spec: + interval: 1h + sourceRef: + kind: GitRepository + name: flux-system + git: + checkout: + ref: + branch: main + commit: + author: + email: fluxcdbot@example.com + name: fluxcdbot + messageTemplate: "Update image" + update: + path: ./clusters/production + strategy: Setters`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'git', + title: 'Git Configuration', + description: 'Repository and commit settings', + defaultExpanded: true + }, + { + id: 'update', + title: 'Update Strategy', + description: 'How to apply changes in Git', + defaultExpanded: true + } + ], + fields: [ + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic' + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system' + }, + { + name: 'sourceName', + label: 'Git Repository', + path: 'spec.sourceRef.name', + type: 'string', + required: true, + section: 'git', + referenceType: 'GitRepository' + }, + { + name: 'branch', + label: 'Branch', + path: 'spec.git.checkout.ref.branch', + type: 'string', + section: 'git', + default: 'main' + }, + { + name: 'updatePath', + label: 'Update Path', + path: 'spec.update.path', + type: 'string', + section: 'update', + default: './', + description: 'Path in Git repository to look for image markers' + }, + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'update', + default: '1h' + } + ] +}; diff --git a/src/lib/templates/index.ts b/src/lib/templates/index.ts index ebb44675..6638816a 100644 --- a/src/lib/templates/index.ts +++ b/src/lib/templates/index.ts @@ -1,3412 +1,32 @@ -export interface ResourceTemplate { - id: string; - name: string; - description: string; - kind: string; - group: string; - version: string; - yaml: string; - fields: TemplateField[]; - sections?: TemplateSection[]; - category?: string; // Added for categorization - plural: string; // API plural form (e.g., 'gitrepositories', 'helmcharts') -} - -export interface TemplateField { - name: string; - label: string; - path: string; // JSON path or similar to update the YAML - type: 'string' | 'number' | 'boolean' | 'select' | 'duration' | 'textarea' | 'array' | 'object'; - default?: string | number | boolean | unknown[]; - options?: { label: string; value: string }[]; - required?: boolean; - description?: string; - section?: string; // Section grouping for fields - placeholder?: string; - showIf?: { - field: string; // Name of field to check - value: string | string[]; // Value(s) that trigger visibility - }; - validation?: { - pattern?: string; // Regex pattern - message?: string; // Custom error message - min?: number; // Min value (for numbers) - max?: number; // Max value (for numbers) - }; - arrayItemType?: 'string' | 'object'; // For array fields - arrayItemFields?: TemplateField[]; // For object array items - objectFields?: TemplateField[]; // For object fields - helpText?: string; // Detailed help text for the field - docsUrl?: string; // Link to FluxCD documentation - virtual?: boolean; // UI-only field, do not persist to YAML - referenceType?: string | string[]; // Resource type(s) to autocomplete from - referenceTypeField?: string; // Field to get the reference type from - referenceNamespaceField?: string; // Sibling field to auto-fill with selected namespace -} - -export interface TemplateSection { - id: string; - title: string; - description?: string; - collapsible?: boolean; - defaultExpanded?: boolean; -} - -const CEL_VALIDATION = { - pattern: '^[a-zA-Z0-9_.()\\[\\]"\' !&|=<>+\\-*/%:, ]{1,500}$', - message: - 'CEL expression must use only alphanumeric characters, operators, and field accessors. Max 500 characters.' -}; - -export const GIT_REPOSITORY_TEMPLATE: ResourceTemplate = { - id: 'git-repository-base', - name: 'Git Repository', - description: 'Sources from a Git repository', - kind: 'GitRepository', - group: 'source.toolkit.fluxcd.io', - version: 'v1', - category: 'sources', - plural: 'gitrepositories', - yaml: `apiVersion: source.toolkit.fluxcd.io/v1 -kind: GitRepository -metadata: - name: example - namespace: flux-system -spec: - interval: 1m - url: https://github.com/org/repo - ref: - branch: main`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'source', - title: 'Source Configuration', - description: 'Git repository source settings', - defaultExpanded: true - }, - { - id: 'auth', - title: 'Authentication', - description: 'Credentials and access control', - collapsible: true, - defaultExpanded: false - }, - { - id: 'verification', - title: 'Verification', - description: 'GPG signature verification settings', - collapsible: true, - defaultExpanded: false - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-repository', - description: 'Unique name for this GitRepository resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Source Configuration - { - name: 'url', - label: 'Repository URL', - path: 'spec.url', - type: 'string', - required: true, - section: 'source', - placeholder: 'https://github.com/fluxcd/flux2', - description: 'Git repository URL (https://, ssh://, or git@)', - helpText: - 'The Git repository URL to sync from. Supports HTTPS (with optional basic auth), SSH (requires secretRef), and GitHub App authentication.', - docsUrl: 'https://fluxcd.io/flux/components/source/gitrepositories/#url', - validation: { - pattern: '^(https?://|ssh://|git@)', - message: 'URL must start with https://, http://, ssh://, or git@' - } - }, - { - name: 'provider', - label: 'Git Provider', - path: 'spec.provider', - type: 'select', - section: 'source', - default: 'generic', - options: [ - { label: 'Generic Git', value: 'generic' }, - { label: 'GitHub', value: 'github' }, - { label: 'Azure DevOps', value: 'azure' } - ], - description: 'Git provider optimization' - }, - { - name: 'refType', - label: 'Reference Type', - path: 'spec.ref.type', - type: 'select', - section: 'source', - default: 'branch', - options: [ - { label: 'Branch', value: 'branch' }, - { label: 'Tag', value: 'tag' }, - { label: 'Semver', value: 'semver' }, - { label: 'Commit', value: 'commit' } - ], - description: 'Type of Git reference to track' - }, - { - name: 'branch', - label: 'Branch', - path: 'spec.ref.branch', - type: 'string', - required: true, - section: 'source', - default: 'main', - placeholder: 'main', - description: 'Branch name to track', - showIf: { - field: 'refType', - value: 'branch' - } - }, - { - name: 'tag', - label: 'Tag', - path: 'spec.ref.tag', - type: 'string', - required: true, - section: 'source', - placeholder: 'v1.0.0', - description: 'Tag name to track', - showIf: { - field: 'refType', - value: 'tag' - } - }, - { - name: 'semver', - label: 'Semver Range', - path: 'spec.ref.semver', - type: 'string', - required: true, - section: 'source', - placeholder: '>=1.0.0', - description: 'Semver range to track', - showIf: { - field: 'refType', - value: 'semver' - }, - validation: { - pattern: '^[><=~^*]?[0-9]+\\.[0-9]+(\\.[0-9]+)?', - message: 'Must be a valid semver constraint (e.g., >=1.0.0, ~1.2.0, ^2.0.0)' - } - }, - { - name: 'commit', - label: 'Commit SHA', - path: 'spec.ref.commit', - type: 'string', - required: true, - section: 'source', - placeholder: 'abc123...', - description: 'Specific commit SHA to track', - showIf: { - field: 'refType', - value: 'commit' - } - }, - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'source', - default: '1m', - placeholder: '1m', - description: 'How often to check for repository changes (e.g., 1m, 1m30s, 1h30m)', - helpText: - 'The interval at which to check the upstream repository for changes. Flux supports: 1h30m, 5m, 30s, etc.', - docsUrl: 'https://fluxcd.io/flux/components/source/gitrepositories/#interval', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - }, - - // Authentication - { - name: 'secretRefName', - label: 'Secret Name', - path: 'spec.secretRef.name', - type: 'string', - section: 'auth', - placeholder: 'git-credentials', - description: 'Name of secret containing authentication credentials' - }, - { - name: 'serviceAccountName', - label: 'Service Account', - path: 'spec.serviceAccountName', - type: 'string', - section: 'auth', - placeholder: 'git-controller', - description: 'ServiceAccount for impersonation' - }, - { - name: 'proxySecretRef', - label: 'Proxy Secret', - path: 'spec.proxySecretRef.name', - type: 'string', - section: 'auth', - placeholder: 'proxy-credentials', - description: 'Secret containing proxy credentials' - }, - - // Verification - { - name: 'verifyMode', - label: 'Verification Mode', - path: 'spec.verify.mode', - type: 'select', - section: 'verification', - default: '', - options: [ - { label: 'Disabled', value: '' }, - { label: 'Head (branch)', value: 'HEAD' }, - { label: 'Tag', value: 'Tag' }, - { label: 'Tag and Head', value: 'TagAndHEAD' } - ], - description: 'Which references to verify with GPG' - }, - { - name: 'verifySecret', - label: 'Verification Secret', - path: 'spec.verify.secretRef.name', - type: 'string', - required: true, - section: 'verification', - placeholder: 'git-pgp-public-keys', - description: 'Secret containing GPG public keys for verification', - showIf: { - field: 'verifyMode', - value: ['HEAD', 'Tag', 'TagAndHEAD'] - } - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend reconciliation of this repository' - }, - { - name: 'timeout', - label: 'Timeout', - path: 'spec.timeout', - type: 'duration', - section: 'advanced', - default: '10m', - placeholder: '60s', - description: 'Timeout for Git operations', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - { - name: 'recurseSubmodules', - label: 'Recurse Submodules', - path: 'spec.recurseSubmodules', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Recursively clone Git submodules' - }, - { - name: 'sparseCheckout', - label: 'Sparse Checkout', - path: 'spec.sparseCheckout.paths', - type: 'array', - section: 'advanced', - arrayItemType: 'string', - placeholder: './dir1', - description: 'List of directories to checkout' - }, - { - name: 'ignore', - label: 'Ignore Paths', - path: 'spec.ignore', - type: 'textarea', - section: 'advanced', - placeholder: '# .gitignore format\n*.txt\n/temp/', - description: 'Paths to ignore when calculating artifact checksum (.gitignore format)' - }, - { - name: 'include', - label: 'Include Repositories', - path: 'spec.include', - type: 'array', - section: 'advanced', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'repository', - label: 'Repository', - path: 'repository', - type: 'string', - required: true, - placeholder: 'other-repo', - referenceType: 'GitRepository' - }, - { - name: 'toPath', - label: 'To Path', - path: 'toPath', - type: 'string', - placeholder: './included' - }, - { - name: 'fromPath', - label: 'From Path', - path: 'fromPath', - type: 'string', - placeholder: './' - } - ], - description: 'Additional Git repositories to include' - } - ] -}; - -export const HELM_REPOSITORY_TEMPLATE: ResourceTemplate = { - id: 'helm-repository-base', - name: 'Helm Repository', - description: 'Sources from a Helm chart repository', - kind: 'HelmRepository', - group: 'source.toolkit.fluxcd.io', - version: 'v1', - category: 'sources', - plural: 'helmrepositories', - yaml: `apiVersion: source.toolkit.fluxcd.io/v1 -kind: HelmRepository -metadata: - name: example - namespace: flux-system -spec: - interval: 5m - url: https://charts.bitnami.com/bitnami`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'source', - title: 'Repository Configuration', - description: 'Helm repository settings', - defaultExpanded: true - }, - { - id: 'auth', - title: 'Authentication', - description: 'Credentials and TLS configuration', - collapsible: true, - defaultExpanded: false - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-helm-repo', - description: 'Unique name for this HelmRepository resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Repository Configuration - { - name: 'type', - label: 'Repository Type', - path: 'spec.type', - type: 'select', - section: 'source', - default: 'default', - options: [ - { label: 'Default (HTTP/S)', value: 'default' }, - { label: 'OCI', value: 'oci' } - ], - description: 'Type of Helm repository' - }, - { - name: 'provider', - label: 'Provider', - path: 'spec.provider', - type: 'select', - section: 'source', - default: 'generic', - options: [ - { label: 'Generic', value: 'generic' }, - { label: 'AWS', value: 'aws' }, - { label: 'Azure', value: 'azure' }, - { label: 'GCP', value: 'gcp' } - ], - description: 'Cloud provider for OCI repository', - showIf: { - field: 'type', - value: 'oci' - } - }, - { - name: 'url', - label: 'Repository URL', - path: 'spec.url', - type: 'string', - required: true, - section: 'source', - placeholder: 'https://charts.bitnami.com/bitnami', - description: 'HTTP/S Helm repository URL', - validation: { - pattern: '^https?://', - message: 'URL must start with https:// or http://' - }, - showIf: { - field: 'type', - value: 'default' - } - }, - { - name: 'url_oci', - label: 'Repository URL', - path: 'spec.url', - type: 'string', - required: true, - section: 'source', - placeholder: 'oci://ghcr.io/org/charts', - description: 'OCI registry URL (must start with oci://)', - validation: { - pattern: '^oci://', - message: 'OCI repository URL must start with oci://' - }, - showIf: { - field: 'type', - value: 'oci' - } - }, - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'source', - default: '5m', - placeholder: '5m', - description: 'How often to check for new chart versions', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - }, - - // Authentication - { - name: 'secretRefName', - label: 'Secret Name', - path: 'spec.secretRef.name', - type: 'string', - section: 'auth', - placeholder: 'helm-repo-credentials', - description: - 'Secret containing authentication credentials (username/password or certFile/keyFile)' - }, - { - name: 'passCredentials', - label: 'Pass Credentials', - path: 'spec.passCredentials', - type: 'boolean', - section: 'auth', - default: false, - description: 'Pass credentials to all domains' - }, - { - name: 'insecure', - label: 'Insecure', - path: 'spec.insecure', - type: 'boolean', - section: 'auth', - default: false, - description: 'Allow insecure connections (skip TLS verification)' - }, - { - name: 'certSecretRef', - label: 'TLS Secret', - path: 'spec.certSecretRef.name', - type: 'string', - section: 'auth', - placeholder: 'helm-tls-certs', - description: 'Secret containing CA/cert/key for TLS authentication' - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend reconciliation of this repository' - }, - { - name: 'accessFrom', - label: 'Access From', - path: 'spec.accessFrom.namespaceSelectors', - type: 'array', - section: 'advanced', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'matchLabels', - label: 'Match Labels', - path: 'matchLabels', - type: 'textarea', - placeholder: 'role: frontend' - } - ], - description: 'Cross-namespace access control' - }, - { - name: 'timeout', - label: 'Timeout', - path: 'spec.timeout', - type: 'duration', - section: 'advanced', - default: '10m', - placeholder: '60s', - description: 'Timeout for index download operations', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - } - ] -}; - -export const KUSTOMIZATION_TEMPLATE: ResourceTemplate = { - id: 'kustomization-base', - name: 'Kustomization', - description: 'Deploys resources defined in a source via Kustomize', - kind: 'Kustomization', - group: 'kustomize.toolkit.fluxcd.io', - version: 'v1', - category: 'deployments', - plural: 'kustomizations', - yaml: `apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: example - namespace: flux-system -spec: - interval: 5m - path: ./deploy - prune: false - sourceRef: - kind: GitRepository - name: flux-system`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'source', - title: 'Source Configuration', - description: 'Source reference and path settings', - defaultExpanded: true - }, - { - id: 'deployment', - title: 'Deployment Settings', - description: 'Reconciliation and deployment options', - defaultExpanded: true - }, - { - id: 'health', - title: 'Health Checks', - description: 'Health assessment and wait configuration', - collapsible: true, - defaultExpanded: false - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - }, - { - id: 'customization', - title: 'Manifest Customization', - description: 'Metadata and name overrides', - collapsible: true, - defaultExpanded: false - }, - { - id: 'remote', - title: 'Remote Cluster & Decryption', - description: 'KubeConfig and SOPS decryption', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-app', - description: 'Unique name for this Kustomization resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Source Configuration - { - name: 'sourceKind', - label: 'Source Kind', - path: 'spec.sourceRef.kind', - type: 'select', - required: true, - section: 'source', - default: 'GitRepository', - options: [ - { label: 'GitRepository', value: 'GitRepository' }, - { label: 'OCIRepository', value: 'OCIRepository' }, - { label: 'Bucket', value: 'Bucket' } - ], - description: 'Type of source to reconcile from' - }, - { - name: 'sourceName', - label: 'Source Name', - path: 'spec.sourceRef.name', - type: 'string', - required: true, - section: 'source', - placeholder: 'flux-system', - description: 'Name of the source resource', - referenceTypeField: 'sourceKind', - referenceNamespaceField: 'sourceNamespace' - }, - { - name: 'sourceNamespace', - label: 'Source Namespace', - path: 'spec.sourceRef.namespace', - type: 'string', - section: 'source', - placeholder: 'flux-system', - description: 'Namespace of the source (if different from this resource)' - }, - { - name: 'path', - label: 'Path', - path: 'spec.path', - type: 'string', - section: 'source', - default: './', - placeholder: './deploy', - description: 'Path to the directory containing Kustomize files' - }, - - // Deployment Settings - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'deployment', - default: '5m', - placeholder: '5m', - description: 'How often to reconcile the Kustomization', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - }, - { - name: 'prune', - label: 'Prune Resources', - path: 'spec.prune', - type: 'boolean', - section: 'deployment', - default: false, - description: 'Delete resources removed from source' - }, - { - name: 'deletionPolicy', - label: 'Deletion Policy', - path: 'spec.deletionPolicy', - type: 'select', - section: 'deployment', - default: 'MirrorPrune', - options: [ - { label: 'Mirror Prune', value: 'MirrorPrune' }, - { label: 'Delete', value: 'Delete' }, - { label: 'Wait For Termination', value: 'WaitForTermination' }, - { label: 'Orphan', value: 'Orphan' } - ], - description: 'Control garbage collection when Kustomization is deleted' - }, - { - name: 'targetNamespace', - label: 'Target Namespace', - path: 'spec.targetNamespace', - type: 'string', - section: 'deployment', - placeholder: 'default', - description: 'Override namespace for all resources' - }, - { - name: 'dependsOn', - label: 'Dependencies', - path: 'spec.dependsOn', - type: 'array', - section: 'deployment', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'name', - label: 'Name', - path: 'name', - type: 'string', - required: true, - placeholder: 'common' - }, - { - name: 'namespace', - label: 'Namespace', - path: 'namespace', - type: 'string', - placeholder: 'flux-system' - } - ], - description: 'List of Kustomizations this depends on' - }, - - // Health Checks - { - name: 'wait', - label: 'Wait for Resources', - path: 'spec.wait', - type: 'boolean', - section: 'health', - default: false, - description: 'Wait for all resources to become ready' - }, - { - name: 'healthChecks', - label: 'Health Checks', - path: 'spec.healthChecks', - type: 'array', - section: 'health', - arrayItemType: 'object', - arrayItemFields: [ - { name: 'kind', label: 'Kind', path: 'kind', type: 'string', required: true }, - { name: 'name', label: 'Name', path: 'name', type: 'string', required: true }, - { name: 'namespace', label: 'Namespace', path: 'namespace', type: 'string' } - ], - description: 'List of resources to be included in health assessment' - }, - { - name: 'healthCheckExprs', - label: 'Health Check Expressions (CEL)', - path: 'spec.healthCheckExprs', - type: 'array', - section: 'health', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'apiVersion', - label: 'API Version', - path: 'apiVersion', - type: 'string', - required: true - }, - { name: 'kind', label: 'Kind', path: 'kind', type: 'string', required: true }, - { - name: 'inProgress', - label: 'In Progress Expression', - path: 'inProgress', - type: 'textarea', - description: 'CEL expression to check if the resource is still progressing', - validation: CEL_VALIDATION - }, - { - name: 'failed', - label: 'Failed Expression', - path: 'failed', - type: 'textarea', - description: 'CEL expression to check if the resource has failed', - validation: CEL_VALIDATION - }, - { - name: 'current', - label: 'Current Expression', - path: 'current', - type: 'textarea', - required: true, - description: 'CEL expression to check if the resource is healthy', - validation: CEL_VALIDATION - } - ], - description: - 'CEL expressions for health assessment. Evaluation order: inProgress → failed → current', - helpText: - 'CEL expressions evaluated in order: inProgress (progressing), failed (unhealthy), current (healthy).' - }, - { - name: 'timeout', - label: 'Timeout', - path: 'spec.timeout', - type: 'duration', - section: 'health', - default: '10m', - placeholder: '5m', - description: 'Timeout for health checks and operations', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend reconciliation' - }, - { - name: 'force', - label: 'Force Apply', - path: 'spec.force', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Force resource updates through delete/recreate if needed' - }, - { - name: 'serviceAccountName', - label: 'Service Account', - path: 'spec.serviceAccountName', - type: 'string', - section: 'advanced', - placeholder: 'kustomize-controller', - description: 'ServiceAccount to impersonate for reconciliation' - }, - { - name: 'retryInterval', - label: 'Retry Interval', - path: 'spec.retryInterval', - type: 'duration', - section: 'advanced', - placeholder: '1m', - description: 'How often to retry after a failure', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - { - name: 'components', - label: 'Components', - path: 'spec.components', - type: 'array', - section: 'advanced', - arrayItemType: 'string', - placeholder: './components/feature-a', - description: 'List of Kustomize components' - }, - { - name: 'ignoreMissingComponents', - label: 'Ignore Missing Components', - path: 'spec.ignoreMissingComponents', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Ignore component paths not found in source' - }, - { - name: 'commonMetadataLabels', - label: 'Common Labels', - path: 'spec.commonMetadata.labels', - type: 'textarea', - section: 'customization', - placeholder: 'app: my-app\nenv: prod', - description: 'Labels to apply to all resources (YAML format)' - }, - { - name: 'commonMetadataAnnotations', - label: 'Common Annotations', - path: 'spec.commonMetadata.annotations', - type: 'textarea', - section: 'customization', - placeholder: 'team: frontend', - description: 'Annotations to apply to all resources (YAML format)' - }, - { - name: 'namePrefix', - label: 'Name Prefix', - path: 'spec.namePrefix', - type: 'string', - section: 'customization', - placeholder: 'prod-', - description: 'Prefix to add to all resource names' - }, - { - name: 'nameSuffix', - label: 'Name Suffix', - path: 'spec.nameSuffix', - type: 'string', - section: 'customization', - placeholder: '-v1', - description: 'Suffix to add to all resource names' - }, - { - name: 'postBuildSubstitute', - label: 'Variable Substitution', - path: 'spec.postBuild.substitute', - type: 'textarea', - section: 'customization', - placeholder: 'cluster_name: prod-cluster', - description: - 'Key-value pairs for variable substitution (YAML format). Keys must be valid identifiers (letters, digits, underscores; cannot start with a digit).' - }, - { - name: 'kubeConfigSecret', - label: 'Remote KubeConfig Secret', - path: 'spec.kubeConfig.secretRef.name', - type: 'string', - section: 'remote', - placeholder: 'remote-cluster-kubeconfig', - description: 'Secret containing KubeConfig for remote cluster' - }, - { - name: 'decryptionProvider', - label: 'Decryption Provider', - path: 'spec.decryption.provider', - type: 'select', - section: 'remote', - default: '', - options: [ - { label: 'None', value: '' }, - { label: 'SOPS', value: 'sops' } - ], - description: 'Provider for Secrets decryption' - }, - { - name: 'decryptionSecret', - label: 'Decryption Secret', - path: 'spec.decryption.secretRef.name', - type: 'string', - section: 'remote', - placeholder: 'sops-gpg', - description: 'Secret containing decryption keys', - showIf: { - field: 'decryptionProvider', - value: 'sops' - } - }, - { - name: 'images', - label: 'Images', - path: 'spec.images', - type: 'array', - section: 'advanced', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'name', - label: 'Original Name', - path: 'name', - type: 'string', - required: true, - placeholder: 'ghcr.io/stefanprodan/podinfo' - }, - { - name: 'newName', - label: 'New Name', - path: 'newName', - type: 'string', - placeholder: 'registry.example.com/podinfo' - }, - { - name: 'newTag', - label: 'New Tag', - path: 'newTag', - type: 'string', - placeholder: 'v1.0.0' - }, - { - name: 'digest', - label: 'Digest', - path: 'digest', - type: 'string', - placeholder: 'sha256:...' - } - ], - description: 'Override container images' - }, - { - name: 'patches', - label: 'Strategic Merge Patches', - path: 'spec.patches', - type: 'array', - section: 'advanced', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'target', - label: 'Target', - path: 'target', - type: 'textarea', - placeholder: 'group: apps\nversion: v1\nkind: Deployment\nname: my-app' - }, - { - name: 'patch', - label: 'Patch', - path: 'patch', - type: 'textarea', - placeholder: 'apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app' - } - ], - description: 'Strategic merge patches to apply' - } - ] -}; - -export const HELM_RELEASE_TEMPLATE: ResourceTemplate = { - id: 'helm-release-base', - name: 'Helm Release', - description: 'Deploys a Helm chart', - kind: 'HelmRelease', - group: 'helm.toolkit.fluxcd.io', - version: 'v2', - category: 'deployments', - plural: 'helmreleases', - yaml: `apiVersion: helm.toolkit.fluxcd.io/v2 -kind: HelmRelease -metadata: - name: example - namespace: flux-system -spec: - interval: 5m - chart: - spec: - chart: podinfo - version: ">=1.0.0" - sourceRef: - kind: HelmRepository - name: bitnami - namespace: flux-system`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'chart', - title: 'Chart Configuration', - description: 'Helm chart source and version', - defaultExpanded: true - }, - { - id: 'release', - title: 'Release Settings', - description: 'Helm release configuration', - defaultExpanded: true - }, - { - id: 'upgrade', - title: 'Upgrade & Rollback', - description: 'Upgrade and rollback behavior', - collapsible: true, - defaultExpanded: false - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - }, - { - id: 'drift', - title: 'Drift Detection', - description: 'Drift detection and correction', - collapsible: true, - defaultExpanded: false - }, - { - id: 'resourceLimits', - title: 'Resource Limits', - description: 'CPU and memory constraints for deployed workloads', - collapsible: true, - defaultExpanded: false - }, - { - id: 'install', - title: 'Install Options', - description: 'Helm install action configuration', - collapsible: true, - defaultExpanded: false - }, - { - id: 'test', - title: 'Helm Test', - description: 'Helm test action configuration', - collapsible: true, - defaultExpanded: false - }, - { - id: 'uninstall', - title: 'Uninstall Options', - description: 'Helm uninstall action configuration', - collapsible: true, - defaultExpanded: false - }, - { - id: 'remote', - title: 'Remote Cluster', - description: 'KubeConfig for remote cluster', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-release', - description: 'Unique name for this HelmRelease resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Chart Configuration - { - name: 'chartSourceKind', - label: 'Chart Source Kind', - path: 'spec.chart.spec.sourceRef.kind', - type: 'select', - required: true, - section: 'chart', - default: 'HelmRepository', - options: [ - { label: 'HelmRepository', value: 'HelmRepository' }, - { label: 'GitRepository', value: 'GitRepository' }, - { label: 'Bucket', value: 'Bucket' } - ], - description: 'Type of source containing the chart' - }, - { - name: 'chartSourceName', - label: 'Chart Source Name', - path: 'spec.chart.spec.sourceRef.name', - type: 'string', - required: true, - section: 'chart', - placeholder: 'bitnami', - description: 'Name of the source resource', - referenceTypeField: 'chartSourceKind', - referenceNamespaceField: 'chartSourceNamespace' - }, - { - name: 'chartSourceNamespace', - label: 'Chart Source Namespace', - path: 'spec.chart.spec.sourceRef.namespace', - type: 'string', - section: 'chart', - placeholder: 'flux-system', - description: 'Namespace of the chart source' - }, - { - name: 'chartName', - label: 'Chart Name', - path: 'spec.chart.spec.chart', - type: 'string', - required: true, - section: 'chart', - placeholder: 'podinfo', - description: 'Name of the Helm chart' - }, - { - name: 'chartVersion', - label: 'Chart Version', - path: 'spec.chart.spec.version', - type: 'string', - section: 'chart', - default: '*', - placeholder: '>=1.0.0 <2.0.0', - description: 'SemVer version constraint or specific version' - }, - - // Release Settings - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'release', - default: '5m', - placeholder: '5m', - description: 'How often to reconcile the release', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - }, - { - name: 'targetNamespace', - label: 'Target Namespace', - path: 'spec.targetNamespace', - type: 'string', - section: 'release', - placeholder: 'default', - description: 'Namespace to install the release into' - }, - { - name: 'storageNamespace', - label: 'Storage Namespace', - path: 'spec.storageNamespace', - type: 'string', - section: 'release', - placeholder: 'flux-system', - description: 'Namespace where Helm stores release state' - }, - { - name: 'releaseName', - label: 'Release Name', - path: 'spec.releaseName', - type: 'string', - section: 'release', - placeholder: 'my-app', - description: 'Helm release name (defaults to metadata.name)' - }, - { - name: 'values', - label: 'Values', - path: 'spec.values', - type: 'textarea', - section: 'release', - placeholder: 'replicaCount: 3\nimage:\n tag: v1.0.0', - description: - "Helm values to override (YAML format). Values are passed directly to the chart — ensure they match the chart's values schema." - }, - { - name: 'valuesFiles', - label: 'Values Files', - path: 'spec.chart.spec.valuesFiles', - type: 'array', - section: 'release', - arrayItemType: 'string', - placeholder: 'values.yaml', - description: 'List of values files to use from the chart' - }, - { - name: 'valuesFrom', - label: 'Values From', - path: 'spec.valuesFrom', - type: 'array', - section: 'release', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'kind', - label: 'Kind', - path: 'kind', - type: 'select', - options: [ - { label: 'ConfigMap', value: 'ConfigMap' }, - { label: 'Secret', value: 'Secret' } - ] - }, - { - name: 'name', - label: 'Name', - path: 'name', - type: 'string' - }, - { - name: 'namespace', - label: 'Namespace', - path: 'namespace', - type: 'string', - placeholder: 'flux-system', - description: - 'Namespace of the referenced resource. Leave blank to use the HelmRelease namespace.' - }, - { name: 'valuesKey', label: 'Values Key', path: 'valuesKey', type: 'string' }, - { name: 'targetPath', label: 'Target Path', path: 'targetPath', type: 'string' }, - { name: 'optional', label: 'Optional', path: 'optional', type: 'boolean' } - ], - description: 'References to ConfigMaps or Secrets containing Helm values.', - helpText: - "Security: valuesFrom can reference resources from any namespace if the controller's RBAC permits it. Prefer referencing Secrets and ConfigMaps in the same namespace as the HelmRelease to limit exposure." - }, - { - name: 'dependsOn', - label: 'Dependencies', - path: 'spec.dependsOn', - type: 'array', - section: 'release', - arrayItemType: 'object', - arrayItemFields: [ - { name: 'name', label: 'Name', path: 'name', type: 'string' }, - { name: 'namespace', label: 'Namespace', path: 'namespace', type: 'string' } - ], - description: 'List of HelmReleases this depends on' - }, - { - name: 'commonMetadataLabels', - label: 'Common Labels', - path: 'spec.commonMetadata.labels', - type: 'textarea', - section: 'release', - placeholder: 'app: my-app', - description: - 'Common labels applied to all managed resources (YAML format). Keys max 63 chars, values max 63 chars. Valid chars: alphanumeric, hyphens, underscores, dots.' - }, - { - name: 'commonMetadataAnnotations', - label: 'Common Annotations', - path: 'spec.commonMetadata.annotations', - type: 'textarea', - section: 'release', - placeholder: 'team: frontend', - description: 'Annotations to apply to all resources' - }, - { - name: 'resourceLimitsCpu', - label: 'CPU Limit', - path: 'spec.values.resources.limits.cpu', - type: 'string', - section: 'resourceLimits', - placeholder: '500m', - description: - 'Maximum CPU for deployed pods (e.g. 500m, 1). Sets spec.values.resources.limits.cpu.', - helpText: - 'Most Helm charts expose resources.limits.cpu in their values. If your chart uses a different key, edit spec.values directly.' - }, - { - name: 'resourceLimitsMemory', - label: 'Memory Limit', - path: 'spec.values.resources.limits.memory', - type: 'string', - section: 'resourceLimits', - placeholder: '128Mi', - description: - 'Maximum memory for deployed pods (e.g. 128Mi, 1Gi). Sets spec.values.resources.limits.memory.' - }, - { - name: 'resourceRequestsCpu', - label: 'CPU Request', - path: 'spec.values.resources.requests.cpu', - type: 'string', - section: 'resourceLimits', - placeholder: '100m', - description: - 'Requested CPU for scheduling (e.g. 100m). Sets spec.values.resources.requests.cpu.' - }, - { - name: 'resourceRequestsMemory', - label: 'Memory Request', - path: 'spec.values.resources.requests.memory', - type: 'string', - section: 'resourceLimits', - placeholder: '64Mi', - description: - 'Requested memory for scheduling (e.g. 64Mi). Sets spec.values.resources.requests.memory.' - }, - - // Upgrade & Rollback - { - name: 'upgradeForce', - label: 'Force Upgrade', - path: 'spec.upgrade.force', - type: 'boolean', - section: 'upgrade', - default: false, - description: 'Force resource updates through delete/recreate' - }, - { - name: 'upgradeCleanupOnFail', - label: 'Cleanup on Fail', - path: 'spec.upgrade.cleanupOnFail', - type: 'boolean', - section: 'upgrade', - default: true, - description: 'Delete resources created during failed upgrade' - }, - { - name: 'rollbackCleanupOnFail', - label: 'Cleanup on Rollback Fail', - path: 'spec.rollback.cleanupOnFail', - type: 'boolean', - section: 'upgrade', - default: true, - description: 'Delete resources created during failed rollback' - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend reconciliation' - }, - { - name: 'timeout', - label: 'Timeout', - path: 'spec.timeout', - type: 'duration', - section: 'advanced', - default: '10m', - placeholder: '5m', - description: 'Timeout for Helm operations', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - { - name: 'serviceAccountName', - label: 'Service Account', - path: 'spec.serviceAccountName', - type: 'string', - section: 'advanced', - placeholder: 'helm-controller', - description: 'ServiceAccount to impersonate for Helm operations' - }, - { - name: 'persistentClient', - label: 'Persistent Client', - path: 'spec.persistentClient', - type: 'boolean', - section: 'advanced', - default: true, - description: 'Use a persistent Kubernetes client for this release' - }, - { - name: 'maxHistory', - label: 'Max History', - path: 'spec.maxHistory', - type: 'number', - section: 'advanced', - default: 5, - description: 'Max number of release revisions to keep' - }, - { - name: 'driftMode', - label: 'Drift Detection Mode', - path: 'spec.driftDetection.mode', - type: 'select', - section: 'drift', - default: 'warn', - options: [ - { label: 'Disabled', value: 'disabled' }, - { label: 'Warn', value: 'warn' }, - { label: 'Enabled (Automatic Correction)', value: 'enabled' } - ], - description: 'Mode for drift detection and correction' - }, - { - name: 'installCRDs', - label: 'Install CRDs', - path: 'spec.install.crds', - type: 'select', - section: 'install', - default: 'Create', - options: [ - { label: 'Skip', value: 'Skip' }, - { label: 'Create', value: 'Create' }, - { label: 'CreateReplace', value: 'CreateReplace' } - ] - }, - { - name: 'createNamespace', - label: 'Create Namespace', - path: 'spec.install.createNamespace', - type: 'boolean', - section: 'install', - default: false, - description: 'Create target namespace if it does not exist' - }, - { - name: 'testEnabled', - label: 'Enable Helm Test', - path: 'spec.test.enable', - type: 'boolean', - section: 'test', - default: false, - description: 'Run helm test after install/upgrade' - }, - { - name: 'uninstallKeepHistory', - label: 'Keep History on Uninstall', - path: 'spec.uninstall.keepHistory', - type: 'boolean', - section: 'uninstall', - default: false, - description: 'Retain release history after uninstall' - }, - { - name: 'kubeConfigSecret', - label: 'Remote KubeConfig Secret', - path: 'spec.kubeConfig.secretRef.name', - type: 'string', - section: 'remote', - placeholder: 'remote-cluster-kubeconfig', - description: 'Secret containing KubeConfig for remote cluster' - }, - { - name: 'postRenderers', - label: 'Post Renderers', - path: 'spec.postRenderers', - type: 'array', - section: 'advanced', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'kustomize', - label: 'Kustomize Config', - path: 'kustomize', - type: 'textarea', - placeholder: 'patches:\n - target:\n group: apps' - } - ], - description: - 'Post-renderers to apply to rendered manifests. Only trusted YAML should be used here as patches are applied with cluster write permissions.' - } - ] -}; - -export const HELM_CHART_TEMPLATE: ResourceTemplate = { - id: 'helm-chart-base', - name: 'Helm Chart', - description: 'References a Helm chart from a repository', - kind: 'HelmChart', - group: 'source.toolkit.fluxcd.io', - version: 'v1', - category: 'sources', - plural: 'helmcharts', - yaml: `apiVersion: source.toolkit.fluxcd.io/v1 -kind: HelmChart -metadata: - name: example - namespace: flux-system -spec: - interval: 5m - chart: podinfo - version: ">=1.0.0" - sourceRef: - kind: HelmRepository - name: podinfo`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'chart', - title: 'Chart Configuration', - description: 'Chart source and version', - defaultExpanded: true - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-chart', - description: 'Unique name for this HelmChart resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Chart Configuration - { - name: 'sourceKind', - label: 'Source Kind', - path: 'spec.sourceRef.kind', - type: 'select', - required: true, - section: 'chart', - default: 'HelmRepository', - options: [ - { label: 'HelmRepository', value: 'HelmRepository' }, - { label: 'GitRepository', value: 'GitRepository' }, - { label: 'Bucket', value: 'Bucket' } - ], - description: 'Type of source containing the chart' - }, - { - name: 'sourceName', - label: 'Source Name', - path: 'spec.sourceRef.name', - type: 'string', - required: true, - section: 'chart', - placeholder: 'podinfo', - description: 'Name of the source resource', - referenceTypeField: 'sourceKind' - }, - { - name: 'chart', - label: 'Chart Name', - path: 'spec.chart', - type: 'string', - required: true, - section: 'chart', - placeholder: 'podinfo', - description: 'Name of the Helm chart' - }, - { - name: 'version', - label: 'Chart Version', - path: 'spec.version', - type: 'string', - section: 'chart', - default: '*', - placeholder: '>=1.0.0', - description: 'SemVer version constraint' - }, - { - name: 'reconcileStrategy', - label: 'Reconcile Strategy', - path: 'spec.reconcileStrategy', - type: 'select', - section: 'chart', - default: 'ChartVersion', - options: [ - { label: 'Chart Version', value: 'ChartVersion' }, - { label: 'Revision', value: 'Revision' } - ], - description: 'What enables the creation of a new artifact' - }, - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'chart', - default: '5m', - placeholder: '5m', - description: 'How often to check for new chart versions', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend reconciliation' - }, - { - name: 'valuesFiles', - label: 'Values Files', - path: 'spec.valuesFiles', - type: 'textarea', - section: 'advanced', - placeholder: '- values.yaml\n- values-prod.yaml', - description: 'List of values files to merge (YAML array format)' - }, - { - name: 'ignoreMissingValuesFiles', - label: 'Ignore Missing Values Files', - path: 'spec.ignoreMissingValuesFiles', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Silently ignore missing values files rather than failing' - } - ] -}; - -export const BUCKET_TEMPLATE: ResourceTemplate = { - id: 'bucket-base', - name: 'Bucket', - description: 'Sources from an S3-compatible bucket', - kind: 'Bucket', - group: 'source.toolkit.fluxcd.io', - version: 'v1', - category: 'sources', - plural: 'buckets', - yaml: `apiVersion: source.toolkit.fluxcd.io/v1 -kind: Bucket -metadata: - name: example - namespace: flux-system -spec: - interval: 5m - provider: generic - bucketName: my-bucket - endpoint: s3.amazonaws.com`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'bucket', - title: 'Bucket Configuration', - description: 'S3-compatible bucket settings', - defaultExpanded: true - }, - { - id: 'auth', - title: 'Authentication', - description: 'Credentials and access configuration', - collapsible: true, - defaultExpanded: false - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-bucket', - description: 'Unique name for this Bucket resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Bucket Configuration - { - name: 'provider', - label: 'Provider', - path: 'spec.provider', - type: 'select', - required: true, - section: 'bucket', - default: 'generic', - options: [ - { label: 'Generic S3', value: 'generic' }, - { label: 'AWS S3', value: 'aws' }, - { label: 'Google Cloud Storage', value: 'gcp' }, - { label: 'Azure Blob Storage', value: 'azure' } - ], - description: 'Cloud provider type' - }, - { - name: 'bucketName', - label: 'Bucket Name', - path: 'spec.bucketName', - type: 'string', - required: true, - section: 'bucket', - placeholder: 'my-artifacts', - description: 'Name of the S3 bucket' - }, - { - name: 'prefix', - label: 'Prefix', - path: 'spec.prefix', - type: 'string', - section: 'bucket', - placeholder: 'path/to/artifacts/', - description: 'Object prefix to filter objects in the bucket' - }, - { - name: 'endpoint', - label: 'Endpoint', - path: 'spec.endpoint', - type: 'string', - required: true, - section: 'bucket', - placeholder: 's3.amazonaws.com', - description: 'S3-compatible endpoint URL' - }, - { - name: 'region', - label: 'Region', - path: 'spec.region', - type: 'string', - section: 'bucket', - placeholder: 'us-east-1', - description: 'Bucket region' - }, - { - name: 'sts', - label: 'STS Configuration', - path: 'spec.sts', - type: 'object', - section: 'bucket', - objectFields: [ - { - name: 'provider', - label: 'Provider', - path: 'provider', - type: 'string', - required: true - }, - { - name: 'endpoint', - label: 'Endpoint', - path: 'endpoint', - type: 'string', - required: true - }, - { - name: 'secretRef', - label: 'Secret', - path: 'secretRef', - type: 'object', - objectFields: [ - { - name: 'name', - label: 'Secret Name', - path: 'name', - type: 'string' - } - ] - }, - { - name: 'certSecretRef', - label: 'TLS Secret', - path: 'certSecretRef', - type: 'object', - objectFields: [ - { - name: 'name', - label: 'Secret Name', - path: 'name', - type: 'string' - } - ] - } - ], - description: 'Security Token Service configuration' - }, - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'bucket', - default: '5m', - placeholder: '5m', - description: 'How often to check for changes', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - }, - - // Authentication - { - name: 'secretRefName', - label: 'Secret Name', - path: 'spec.secretRef.name', - type: 'string', - section: 'auth', - placeholder: 's3-credentials', - description: 'Secret containing access key and secret key' - }, - { - name: 'serviceAccountName', - label: 'Service Account', - path: 'spec.serviceAccountName', - type: 'string', - section: 'auth', - placeholder: 'bucket-puller', - description: 'ServiceAccount for cloud provider authentication' - }, - { - name: 'certSecretRef', - label: 'TLS Secret', - path: 'spec.certSecretRef.name', - type: 'string', - section: 'auth', - placeholder: 'bucket-tls-certs', - description: 'Secret containing CA/cert/key for TLS authentication (Generic provider only)' - }, - { - name: 'proxySecretRef', - label: 'Proxy Secret', - path: 'spec.proxySecretRef.name', - type: 'string', - section: 'auth', - placeholder: 'proxy-credentials', - description: 'Secret containing proxy credentials' - }, - { - name: 'insecure', - label: 'Insecure', - path: 'spec.insecure', - type: 'boolean', - section: 'auth', - default: false, - description: 'Allow insecure connections (skip TLS verification)' - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend reconciliation' - }, - { - name: 'timeout', - label: 'Timeout', - path: 'spec.timeout', - type: 'duration', - section: 'advanced', - default: '10m', - placeholder: '60s', - description: 'Timeout for bucket operations', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - { - name: 'ignore', - label: 'Ignore Paths', - path: 'spec.ignore', - type: 'textarea', - section: 'advanced', - placeholder: '# .gitignore format\n*.txt', - description: 'Paths to ignore when calculating artifact checksum' - } - ] -}; - -export const OCI_REPOSITORY_TEMPLATE: ResourceTemplate = { - id: 'oci-repository-base', - name: 'OCI Repository', - description: 'Sources from an OCI registry', - kind: 'OCIRepository', - group: 'source.toolkit.fluxcd.io', - version: 'v1beta2', - category: 'sources', - plural: 'ocirepositories', - yaml: `apiVersion: source.toolkit.fluxcd.io/v1beta2 -kind: OCIRepository -metadata: - name: example - namespace: flux-system -spec: - interval: 5m - url: oci://ghcr.io/org/manifests - ref: - tag: v1.0.0`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'source', - title: 'OCI Configuration', - description: 'OCI registry and artifact settings', - defaultExpanded: true - }, - { - id: 'auth', - title: 'Authentication', - description: 'Registry credentials', - collapsible: true, - defaultExpanded: false - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-oci-repo', - description: 'Unique name for this OCIRepository resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // OCI Configuration - { - name: 'url', - label: 'Repository URL', - path: 'spec.url', - type: 'string', - required: true, - section: 'source', - placeholder: 'oci://ghcr.io/org/manifests', - description: 'OCI repository URL (must start with oci://)', - validation: { - pattern: '^oci://', - message: 'OCI repository URL must start with oci://' - } - }, - { - name: 'provider', - label: 'OCI Provider', - path: 'spec.provider', - type: 'select', - section: 'source', - default: 'generic', - options: [ - { label: 'Generic', value: 'generic' }, - { label: 'AWS', value: 'aws' }, - { label: 'Azure', value: 'azure' }, - { label: 'GCP', value: 'gcp' } - ], - description: 'Cloud provider for OCI registry authentication' - }, - { - name: 'refType', - label: 'Reference Type', - path: 'spec.ref.type', - type: 'select', - section: 'source', - default: 'tag', - options: [ - { label: 'Tag', value: 'tag' }, - { label: 'Semver', value: 'semver' }, - { label: 'Digest', value: 'digest' } - ], - description: 'Type of reference to track' - }, - { - name: 'tag', - label: 'Tag', - path: 'spec.ref.tag', - type: 'string', - section: 'source', - placeholder: 'v1.0.0', - description: - "Tag to track. Avoid 'latest' — pin to an explicit version for reproducible deployments.", - showIf: { - field: 'refType', - value: 'tag' - } - }, - { - name: 'semver', - label: 'Semver Range', - path: 'spec.ref.semver', - type: 'string', - section: 'source', - placeholder: '>=1.0.0', - description: 'Semver range to track', - showIf: { - field: 'refType', - value: 'semver' - }, - validation: { - pattern: '^[><=~^*]?[0-9]+\\.[0-9]+(\\.[0-9]+)?', - message: 'Must be a valid semver constraint (e.g., >=1.0.0, ~1.2.0, ^2.0.0)' - } - }, - { - name: 'digest', - label: 'Digest', - path: 'spec.ref.digest', - type: 'string', - section: 'source', - placeholder: 'sha256:abc123...', - description: 'Digest to track', - showIf: { - field: 'refType', - value: 'digest' - } - }, - { - name: 'layerSelector', - label: 'Layer Selector', - path: 'spec.layerSelector', - type: 'object', - section: 'source', - objectFields: [ - { name: 'mediaType', label: 'Media Type', path: 'mediaType', type: 'string' }, - { - name: 'operation', - label: 'Operation', - path: 'operation', - type: 'select', - options: [ - { label: 'Extract', value: 'extract' }, - { label: 'Copy', value: 'copy' } - ] - } - ], - description: 'Select specific OCI layer to extract or copy' - }, - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'source', - default: '5m', - placeholder: '5m', - description: 'How often to check for changes', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - }, - - // Authentication - { - name: 'secretRefName', - label: 'Secret Name', - path: 'spec.secretRef.name', - type: 'string', - section: 'auth', - placeholder: 'oci-credentials', - description: 'Secret containing registry credentials' - }, - { - name: 'serviceAccountName', - label: 'Service Account', - path: 'spec.serviceAccountName', - type: 'string', - section: 'auth', - placeholder: 'oci-puller', - description: 'ServiceAccount for registry authentication' - }, - { - name: 'proxySecretRef', - label: 'Proxy Secret', - path: 'spec.proxySecretRef.name', - type: 'string', - section: 'auth', - placeholder: 'proxy-credentials', - description: 'Secret containing proxy credentials' - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend reconciliation' - }, - { - name: 'timeout', - label: 'Timeout', - path: 'spec.timeout', - type: 'duration', - section: 'advanced', - default: '10m', - placeholder: '60s', - description: 'Timeout for OCI operations', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - { - name: 'ignore', - label: 'Ignore Paths', - path: 'spec.ignore', - type: 'textarea', - section: 'advanced', - placeholder: '# .gitignore format\n*.md', - description: 'Paths to ignore when calculating artifact checksum' - }, - { - name: 'certSecretRef', - label: 'TLS Secret', - path: 'spec.certSecretRef.name', - type: 'string', - section: 'advanced', - placeholder: 'oci-tls-certs', - description: 'Secret containing CA/cert/key for TLS authentication' - }, - { - name: 'insecure', - label: 'Insecure', - path: 'spec.insecure', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Allow insecure connections (skip TLS verification)' - }, - { - name: 'verify', - label: 'Verify', - path: 'spec.verify', - type: 'object', - section: 'advanced', - objectFields: [ - { - name: 'provider', - label: 'Provider', - path: 'provider', - type: 'select', - options: [ - { label: 'Cosign', value: 'cosign' }, - { label: 'Notation', value: 'notation' } - ] - }, - { - name: 'secretRef', - label: 'Secret', - path: 'secretRef', - type: 'object', - objectFields: [ - { - name: 'name', - label: 'Secret Name', - path: 'name', - type: 'string' - } - ] - } - ], - description: 'Signature verification configuration' - } - ] -}; - -export const ALERT_TEMPLATE: ResourceTemplate = { - id: 'alert-base', - name: 'Alert', - description: 'Sends notifications for FluxCD events', - kind: 'Alert', - group: 'notification.toolkit.fluxcd.io', - version: 'v1beta3', - category: 'notifications', - plural: 'alerts', - yaml: `apiVersion: notification.toolkit.fluxcd.io/v1beta3 -kind: Alert -metadata: - name: example - namespace: flux-system -spec: - providerRef: - name: slack - eventSeverity: info - eventSources: - - kind: GitRepository - name: '*' - - kind: Kustomization - name: '*'`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'notification', - title: 'Notification Settings', - description: 'Provider and severity configuration', - defaultExpanded: true - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Event filtering and summary', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-alert', - description: 'Unique name for this Alert resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Notification Settings - { - name: 'providerName', - label: 'Provider Name', - path: 'spec.providerRef.name', - type: 'string', - required: true, - section: 'notification', - placeholder: 'slack', - description: 'Name of the Provider resource to send notifications to', - referenceType: 'Provider' - }, - { - name: 'eventSources', - label: 'Event Sources', - path: 'spec.eventSources', - type: 'array', - required: true, - section: 'notification', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'kind', - label: 'Kind', - path: 'kind', - type: 'string', - required: true, - placeholder: 'GitRepository', - description: 'Resource kind (e.g., GitRepository, Kustomization)' - }, - { - name: 'name', - label: 'Name', - path: 'name', - type: 'string', - required: true, - placeholder: '* or resource name', - description: 'Resource name; use * to watch all resources of that kind', - referenceTypeField: 'kind', - referenceNamespaceField: 'namespace' - } - ], - placeholder: 'GitRepository', - description: - 'Resources to monitor for events. Use * for name to watch all resources of that kind.', - helpText: - 'Define which FluxCD resources to monitor. Each entry needs a kind (e.g., GitRepository, Kustomization) and name (use * for all).', - docsUrl: 'https://fluxcd.io/flux/components/notification/alerts/#event-sources' - }, - { - name: 'eventSeverity', - label: 'Event Severity', - path: 'spec.eventSeverity', - type: 'select', - section: 'notification', - default: 'info', - options: [ - { label: 'Info (all events)', value: 'info' }, - { label: 'Error (only errors)', value: 'error' } - ], - description: 'Minimum severity level to trigger alerts' - }, - - // Advanced Options - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'advanced', - default: false, - description: 'Suspend sending notifications' - }, - { - name: 'summary', - label: 'Summary', - path: 'spec.summary', - type: 'string', - section: 'advanced', - placeholder: 'Production cluster alerts', - description: - 'Optional summary to include in notifications (Deprecated: use Event Metadata instead)' - }, - { - name: 'eventMetadata', - label: 'Event Metadata', - path: 'spec.eventMetadata', - type: 'textarea', - section: 'advanced', - placeholder: 'cluster: prod-1\nenv: production', - description: 'Additional metadata to include in alerts (YAML format)' - }, - { - name: 'inclusionList', - label: 'Inclusion List', - path: 'spec.inclusionList', - type: 'array', - section: 'advanced', - arrayItemType: 'string', - placeholder: 'Succeeded', - description: 'Specific events to include (if empty, all events are included)' - }, - { - name: 'exclusionList', - label: 'Exclusion List', - path: 'spec.exclusionList', - type: 'array', - section: 'advanced', - arrayItemType: 'string', - placeholder: 'Progressing', - description: 'Events to exclude from notifications' - } - ] -}; - -export const PROVIDER_TEMPLATE: ResourceTemplate = { - id: 'provider-base', - name: 'Provider', - description: 'Configures a notification provider', - kind: 'Provider', - group: 'notification.toolkit.fluxcd.io', - version: 'v1beta3', - category: 'notifications', - plural: 'providers', - yaml: `apiVersion: notification.toolkit.fluxcd.io/v1beta3 -kind: Provider -metadata: - name: slack - namespace: flux-system -spec: - type: slack - channel: general - secretRef: - name: slack-webhook-url`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'provider', - title: 'Provider Configuration', - description: 'Notification provider settings', - defaultExpanded: true - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'slack', - description: 'Unique name for this Provider resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Provider Configuration - { - name: 'type', - label: 'Provider Type', - path: 'spec.type', - type: 'select', - required: true, - section: 'provider', - default: 'slack', - options: [ - { label: 'Slack', value: 'slack' }, - { label: 'Discord', value: 'discord' }, - { label: 'Microsoft Teams', value: 'msteams' }, - { label: 'Rocket', value: 'rocket' }, - { label: 'Google Chat', value: 'googlechat' }, - { label: 'Webex', value: 'webex' }, - { label: 'GitHub', value: 'github' }, - { label: 'GitLab', value: 'gitlab' }, - { label: 'Gitea', value: 'gitea' }, - { label: 'Bitbucket', value: 'bitbucket' }, - { label: 'Bitbucket Server', value: 'bitbucketserver' }, - { label: 'Azure DevOps', value: 'azuredevops' }, - { label: 'Google Pub/Sub', value: 'googlepubsub' }, - { label: 'Generic Webhook', value: 'generic' }, - { label: 'Generic HMAC', value: 'generic-hmac' } - ], - description: 'Type of notification provider' - }, - { - name: 'channel', - label: 'Channel', - path: 'spec.channel', - type: 'string', - section: 'provider', - placeholder: 'general', - description: 'Channel name (for Slack, Discord, etc.)' - }, - { - name: 'username', - label: 'Username', - path: 'spec.username', - type: 'string', - section: 'provider', - placeholder: 'FluxCD Bot', - description: 'Override username for notifications' - }, - { - name: 'secretName', - label: 'Secret Name', - path: 'spec.secretRef.name', - type: 'string', - section: 'provider', - placeholder: 'slack-webhook-url', - description: 'Secret containing webhook URL or credentials (if not using inline address)' - }, - { - name: 'address', - label: 'Address', - path: 'spec.address', - type: 'string', - section: 'provider', - placeholder: 'https://hooks.slack.com/services/...', - description: 'Webhook URL or API address (if not in secret)' - }, - { - name: 'proxy', - label: 'Proxy', - path: 'spec.proxy', - type: 'string', - section: 'provider', - placeholder: 'http://proxy.example.com:8080', - description: 'Proxy address to use for notifications' - }, - { - name: 'tlsCertSecret', - label: 'TLS Certificate Secret', - path: 'spec.certSecretRef.name', - type: 'string', - section: 'provider', - placeholder: 'tls-cert', - description: 'Secret containing TLS certificate' - }, - { - name: 'proxySecretRef', - label: 'Proxy Secret', - path: 'spec.proxySecretRef.name', - type: 'string', - section: 'provider', - placeholder: 'proxy-credentials', - description: 'Secret containing proxy credentials' - }, - { - name: 'timeout', - label: 'Timeout', - path: 'spec.timeout', - type: 'duration', - section: 'provider', - default: '10m', - placeholder: '30s', - description: 'Timeout for sending notifications', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'provider', - default: false, - description: 'Suspend notifications' - }, - { - name: 'serviceAccountName', - label: 'Service Account', - path: 'spec.serviceAccountName', - type: 'string', - section: 'provider', - placeholder: 'notification-controller', - description: 'ServiceAccount for cloud provider authentication' - }, - { - name: 'commitStatusExpr', - label: 'Commit Status Expression', - path: 'spec.commitStatusExpr', - type: 'textarea', - section: 'provider', - placeholder: 'event.message', - description: 'CEL expression for custom commit status message', - validation: CEL_VALIDATION - } - ] -}; - -export const RECEIVER_TEMPLATE: ResourceTemplate = { - id: 'receiver-base', - name: 'Receiver', - description: 'Webhook receiver for external events', - kind: 'Receiver', - group: 'notification.toolkit.fluxcd.io', - version: 'v1', - category: 'notifications', - plural: 'receivers', - yaml: `apiVersion: notification.toolkit.fluxcd.io/v1 -kind: Receiver -metadata: - name: example - namespace: flux-system -spec: - type: github - events: - - "ping" - - "push" - secretRef: - name: webhook-token - resources: - - kind: GitRepository - name: webapp`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'receiver', - title: 'Receiver Configuration', - description: 'Webhook receiver settings', - defaultExpanded: true - } - ], - fields: [ - // Basic Information - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'github-receiver', - description: 'Unique name for this Receiver resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - description: 'Namespace where the resource will be created', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - - // Receiver Configuration - { - name: 'type', - label: 'Receiver Type', - path: 'spec.type', - type: 'select', - required: true, - section: 'receiver', - default: 'github', - options: [ - { label: 'GitHub', value: 'github' }, - { label: 'GitLab', value: 'gitlab' }, - { label: 'Bitbucket', value: 'bitbucket' }, - { label: 'Harbor', value: 'harbor' }, - { label: 'DockerHub', value: 'dockerhub' }, - { label: 'Quay', value: 'quay' }, - { label: 'Nexus', value: 'nexus' }, - { label: 'ACR', value: 'acr' }, - { label: 'GCR', value: 'gcr' }, - { label: 'CDEvents', value: 'cdevents' }, - { label: 'Generic Webhook', value: 'generic' }, - { label: 'Generic HMAC', value: 'generic-hmac' } - ], - description: 'Type of webhook receiver' - }, - { - name: 'resources', - label: 'Resources', - path: 'spec.resources', - type: 'array', - required: true, - section: 'receiver', - arrayItemType: 'object', - arrayItemFields: [ - { - name: 'kind', - label: 'Kind', - path: 'kind', - type: 'string', - required: true, - placeholder: 'GitRepository', - description: 'Resource kind (e.g., GitRepository, Kustomization)' - }, - { - name: 'name', - label: 'Name', - path: 'name', - type: 'string', - required: true, - placeholder: '* or resource name', - description: 'Resource name; use * to watch all resources of that kind', - referenceTypeField: 'kind', - referenceNamespaceField: 'namespace' - } - ], - placeholder: 'GitRepository', - description: - 'FluxCD resources to reconcile when webhook is triggered. Use * for name to reconcile all resources of that kind.', - helpText: - 'Define which resources should be reconciled when this webhook receives an event. Each entry needs a kind (e.g., GitRepository, HelmRelease) and name (use * for all).', - docsUrl: 'https://fluxcd.io/flux/components/notification/receivers/#resources' - }, - { - name: 'events', - label: 'Events', - path: 'spec.events', - type: 'array', - section: 'receiver', - arrayItemType: 'string', - placeholder: 'push', - description: 'Specific events to receive (if empty, all events are received)' - }, - { - name: 'secretName', - label: 'Secret Name', - path: 'spec.secretRef.name', - type: 'string', - required: true, - section: 'receiver', - placeholder: 'webhook-token', - description: 'Secret containing webhook validation token' - }, - { - name: 'interval', - label: 'Interval', - path: 'spec.interval', - type: 'duration', - section: 'receiver', - placeholder: '10m', - description: 'Reconciliation interval', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' - } - }, - { - name: 'suspend', - label: 'Suspend', - path: 'spec.suspend', - type: 'boolean', - section: 'receiver', - default: false, - description: 'Suspend webhook processing' - } - ] -}; - -export const IMAGE_REPOSITORY_TEMPLATE: ResourceTemplate = { - id: 'image-repository-base', - name: 'Image Repository', - description: 'Scans container image repositories', - kind: 'ImageRepository', - group: 'image.toolkit.fluxcd.io', - version: 'v1beta2', - category: 'image-automation', - plural: 'imagerepositories', - yaml: `apiVersion: image.toolkit.fluxcd.io/v1beta2 -kind: ImageRepository -metadata: - name: example - namespace: flux-system -spec: - interval: 5m - image: ghcr.io/org/app`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'repository', - title: 'Repository Settings', - description: 'Container registry and scan configuration', - defaultExpanded: true - }, - { - id: 'auth', - title: 'Authentication', - description: 'Registry credentials', - collapsible: true, - defaultExpanded: false - }, - { - id: 'advanced', - title: 'Advanced Options', - description: 'Additional configuration options', - collapsible: true, - defaultExpanded: false - } - ], - fields: [ - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-app', - description: 'Unique name for this ImageRepository resource', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system', - validation: { - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', - message: - 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' - } - }, - { - name: 'image', - label: 'Image', - path: 'spec.image', - type: 'string', - required: true, - section: 'repository', - placeholder: 'ghcr.io/org/app', - description: 'Container image repository to scan' - }, - { - name: 'provider', - label: 'Registry Provider', - path: 'spec.provider', - type: 'select', - section: 'repository', - default: 'generic', - options: [ - { label: 'Generic', value: 'generic' }, - { label: 'AWS', value: 'aws' }, - { label: 'Azure', value: 'azure' }, - { label: 'GCP', value: 'gcp' } - ], - description: 'Cloud provider for registry authentication' - }, - { - name: 'interval', - label: 'Scan Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'repository', - default: '5m', - description: 'How often to scan for new images', - validation: { - pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))*$', - message: - 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' - } - } - ] -}; - -export const IMAGE_POLICY_TEMPLATE: ResourceTemplate = { - id: 'image-policy-base', - name: 'Image Policy', - description: 'Defines policies for selecting image versions', - kind: 'ImagePolicy', - group: 'image.toolkit.fluxcd.io', - version: 'v1beta2', - category: 'image-automation', - plural: 'imagepolicies', - yaml: `apiVersion: image.toolkit.fluxcd.io/v1beta2 -kind: ImagePolicy -metadata: - name: example - namespace: flux-system -spec: - imageRepositoryRef: - name: example - policy: - semver: - range: ">=1.0.0"`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'policy', - title: 'Policy Configuration', - description: 'Rules for selecting images', - defaultExpanded: true - } - ], - fields: [ - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic', - placeholder: 'my-policy', - description: 'Unique name for this ImagePolicy resource' - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system' - }, - { - name: 'imageRepoName', - label: 'Image Repository', - path: 'spec.imageRepositoryRef.name', - type: 'string', - required: true, - section: 'policy', - placeholder: 'my-app', - referenceType: 'ImageRepository', - description: 'ImageRepository to monitor' - }, - { - name: 'policyType', - label: 'Policy Type', - path: 'spec.policy.type', - type: 'select', - section: 'policy', - default: 'semver', - options: [ - { label: 'SemVer', value: 'semver' }, - { label: 'Numerical', value: 'numerical' }, - { label: 'Alphabetical', value: 'alphabetical' } - ] - }, - { - name: 'semverRange', - label: 'Semver Range', - path: 'spec.policy.semver.range', - type: 'string', - section: 'policy', - default: '>=1.0.0', - showIf: { field: 'policyType', value: 'semver' } - } - ] -}; - -export const IMAGE_UPDATE_AUTOMATION_TEMPLATE: ResourceTemplate = { - id: 'image-update-automation-base', - name: 'Image Update Automation', - description: 'Automates image updates to Git', - kind: 'ImageUpdateAutomation', - group: 'image.toolkit.fluxcd.io', - version: 'v1beta2', - category: 'image-automation', - plural: 'imageupdateautomations', - yaml: `apiVersion: image.toolkit.fluxcd.io/v1beta2 -kind: ImageUpdateAutomation -metadata: - name: example - namespace: flux-system -spec: - interval: 1h - sourceRef: - kind: GitRepository - name: flux-system - git: - checkout: - ref: - branch: main - commit: - author: - email: fluxcdbot@example.com - name: fluxcdbot - messageTemplate: "Update image" - update: - path: ./clusters/production - strategy: Setters`, - sections: [ - { - id: 'basic', - title: 'Basic Information', - description: 'Resource identification', - defaultExpanded: true - }, - { - id: 'git', - title: 'Git Configuration', - description: 'Repository and commit settings', - defaultExpanded: true - }, - { - id: 'update', - title: 'Update Strategy', - description: 'How to apply changes in Git', - defaultExpanded: true - } - ], - fields: [ - { - name: 'name', - label: 'Name', - path: 'metadata.name', - type: 'string', - required: true, - section: 'basic' - }, - { - name: 'namespace', - label: 'Namespace', - path: 'metadata.namespace', - type: 'string', - required: true, - section: 'basic', - default: 'flux-system' - }, - { - name: 'sourceName', - label: 'Git Repository', - path: 'spec.sourceRef.name', - type: 'string', - required: true, - section: 'git', - referenceType: 'GitRepository' - }, - { - name: 'branch', - label: 'Branch', - path: 'spec.git.checkout.ref.branch', - type: 'string', - section: 'git', - default: 'main' - }, - { - name: 'updatePath', - label: 'Update Path', - path: 'spec.update.path', - type: 'string', - section: 'update', - default: './', - description: 'Path in Git repository to look for image markers' - }, - { - name: 'interval', - label: 'Sync Interval', - path: 'spec.interval', - type: 'duration', - required: true, - section: 'basic', - default: '1h' - } - ] -}; +import type { ResourceTemplate } from './types.js'; +import { GIT_REPOSITORY_TEMPLATE } from './git-repository.js'; +import { HELM_REPOSITORY_TEMPLATE } from './helm-repository.js'; +import { HELM_CHART_TEMPLATE } from './helm-chart.js'; +import { BUCKET_TEMPLATE } from './bucket.js'; +import { OCI_REPOSITORY_TEMPLATE } from './oci-repository.js'; +import { KUSTOMIZATION_TEMPLATE } from './kustomization.js'; +import { HELM_RELEASE_TEMPLATE } from './helm-release.js'; +import { ALERT_TEMPLATE } from './alert.js'; +import { PROVIDER_TEMPLATE } from './provider.js'; +import { RECEIVER_TEMPLATE } from './receiver.js'; +import { IMAGE_REPOSITORY_TEMPLATE } from './image-repository.js'; +import { IMAGE_POLICY_TEMPLATE } from './image-policy.js'; +import { IMAGE_UPDATE_AUTOMATION_TEMPLATE } from './image-update-automation.js'; + +export * from './types.js'; +export { GIT_REPOSITORY_TEMPLATE } from './git-repository.js'; +export { HELM_REPOSITORY_TEMPLATE } from './helm-repository.js'; +export { KUSTOMIZATION_TEMPLATE } from './kustomization.js'; +export { HELM_RELEASE_TEMPLATE } from './helm-release.js'; +export { HELM_CHART_TEMPLATE } from './helm-chart.js'; +export { BUCKET_TEMPLATE } from './bucket.js'; +export { OCI_REPOSITORY_TEMPLATE } from './oci-repository.js'; +export { ALERT_TEMPLATE } from './alert.js'; +export { PROVIDER_TEMPLATE } from './provider.js'; +export { RECEIVER_TEMPLATE } from './receiver.js'; +export { IMAGE_REPOSITORY_TEMPLATE } from './image-repository.js'; +export { IMAGE_POLICY_TEMPLATE } from './image-policy.js'; +export { IMAGE_UPDATE_AUTOMATION_TEMPLATE } from './image-update-automation.js'; export const templates: ResourceTemplate[] = [ GIT_REPOSITORY_TEMPLATE, @@ -3424,9 +44,6 @@ export const templates: ResourceTemplate[] = [ IMAGE_UPDATE_AUTOMATION_TEMPLATE ]; -/** - * Get the plural form of a resource kind from the template definitions - */ export function getPluralByKind(kind: string): string | undefined { return templates.find((t) => t.kind === kind)?.plural; } diff --git a/src/lib/templates/kustomization.ts b/src/lib/templates/kustomization.ts new file mode 100644 index 00000000..61672ff1 --- /dev/null +++ b/src/lib/templates/kustomization.ts @@ -0,0 +1,513 @@ +import { CEL_VALIDATION, DURATION_VALIDATION, type ResourceTemplate } from './types.js'; + +export const KUSTOMIZATION_TEMPLATE: ResourceTemplate = { + id: 'kustomization-base', + name: 'Kustomization', + description: 'Deploys resources defined in a source via Kustomize', + kind: 'Kustomization', + group: 'kustomize.toolkit.fluxcd.io', + version: 'v1', + category: 'deployments', + plural: 'kustomizations', + yaml: `apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: example + namespace: flux-system +spec: + interval: 5m + path: ./ + prune: false + sourceRef: + kind: GitRepository + name: flux-system`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'source', + title: 'Source Configuration', + description: 'Source reference and path settings', + defaultExpanded: true + }, + { + id: 'deployment', + title: 'Deployment Settings', + description: 'Reconciliation and deployment options', + defaultExpanded: true + }, + { + id: 'health', + title: 'Health Checks', + description: 'Health assessment and wait configuration', + collapsible: true, + defaultExpanded: false + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + }, + { + id: 'customization', + title: 'Manifest Customization', + description: 'Metadata and name overrides', + collapsible: true, + defaultExpanded: false + }, + { + id: 'remote', + title: 'Remote Cluster & Decryption', + description: 'KubeConfig and SOPS decryption', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-app', + description: 'Unique name for this Kustomization resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Source Configuration + { + name: 'sourceKind', + label: 'Source Kind', + path: 'spec.sourceRef.kind', + type: 'select', + required: true, + section: 'source', + default: 'GitRepository', + options: [ + { label: 'GitRepository', value: 'GitRepository' }, + { label: 'OCIRepository', value: 'OCIRepository' }, + { label: 'Bucket', value: 'Bucket' } + ], + description: 'Type of source to reconcile from' + }, + { + name: 'sourceName', + label: 'Source Name', + path: 'spec.sourceRef.name', + type: 'string', + required: true, + section: 'source', + placeholder: 'flux-system', + description: 'Name of the source resource', + referenceTypeField: 'sourceKind', + referenceNamespaceField: 'sourceNamespace' + }, + { + name: 'sourceNamespace', + label: 'Source Namespace', + path: 'spec.sourceRef.namespace', + type: 'string', + section: 'source', + placeholder: 'flux-system', + description: 'Namespace of the source (if different from this resource)' + }, + { + name: 'path', + label: 'Path', + path: 'spec.path', + type: 'string', + section: 'source', + default: './', + placeholder: './', + description: 'Path to the directory containing Kustomize files' + }, + + // Deployment Settings + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'deployment', + default: '5m', + placeholder: '5m', + description: 'How often to reconcile the Kustomization', + validation: DURATION_VALIDATION + }, + { + name: 'prune', + label: 'Prune Resources', + path: 'spec.prune', + type: 'boolean', + section: 'deployment', + default: false, + description: 'Delete resources removed from source' + }, + { + name: 'deletionPolicy', + label: 'Deletion Policy', + path: 'spec.deletionPolicy', + type: 'select', + section: 'deployment', + default: 'MirrorPrune', + options: [ + { label: 'Mirror Prune', value: 'MirrorPrune' }, + { label: 'Delete', value: 'Delete' }, + { label: 'Wait For Termination', value: 'WaitForTermination' }, + { label: 'Orphan', value: 'Orphan' } + ], + description: 'Control garbage collection when Kustomization is deleted' + }, + { + name: 'targetNamespace', + label: 'Target Namespace', + path: 'spec.targetNamespace', + type: 'string', + section: 'deployment', + placeholder: 'default', + description: 'Override namespace for all resources' + }, + { + name: 'dependsOn', + label: 'Dependencies', + path: 'spec.dependsOn', + type: 'array', + section: 'deployment', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'name', + label: 'Name', + path: 'name', + type: 'string', + required: true, + placeholder: 'common' + }, + { + name: 'namespace', + label: 'Namespace', + path: 'namespace', + type: 'string', + placeholder: 'flux-system' + } + ], + description: 'List of Kustomizations this depends on' + }, + + // Health Checks + { + name: 'wait', + label: 'Wait for Resources', + path: 'spec.wait', + type: 'boolean', + section: 'health', + default: false, + description: 'Wait for all resources to become ready' + }, + { + name: 'healthChecks', + label: 'Health Checks', + path: 'spec.healthChecks', + type: 'array', + section: 'health', + arrayItemType: 'object', + arrayItemFields: [ + { name: 'kind', label: 'Kind', path: 'kind', type: 'string', required: true }, + { name: 'name', label: 'Name', path: 'name', type: 'string', required: true }, + { name: 'namespace', label: 'Namespace', path: 'namespace', type: 'string' } + ], + description: 'List of resources to be included in health assessment' + }, + { + name: 'healthCheckExprs', + label: 'Health Check Expressions (CEL)', + path: 'spec.healthCheckExprs', + type: 'array', + section: 'health', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'apiVersion', + label: 'API Version', + path: 'apiVersion', + type: 'string', + required: true + }, + { name: 'kind', label: 'Kind', path: 'kind', type: 'string', required: true }, + { + name: 'inProgress', + label: 'In Progress Expression', + path: 'inProgress', + type: 'textarea', + description: 'CEL expression to check if the resource is still progressing', + validation: CEL_VALIDATION + }, + { + name: 'failed', + label: 'Failed Expression', + path: 'failed', + type: 'textarea', + description: 'CEL expression to check if the resource has failed', + validation: CEL_VALIDATION + }, + { + name: 'current', + label: 'Current Expression', + path: 'current', + type: 'textarea', + required: true, + description: 'CEL expression to check if the resource is healthy', + validation: CEL_VALIDATION + } + ], + description: + 'CEL expressions for health assessment. Evaluation order: inProgress → failed → current', + helpText: + 'CEL expressions evaluated in order: inProgress (progressing), failed (unhealthy), current (healthy).' + }, + { + name: 'timeout', + label: 'Timeout', + path: 'spec.timeout', + type: 'duration', + section: 'health', + default: '10m', + placeholder: '10m', + description: 'Timeout for health checks and operations', + validation: DURATION_VALIDATION + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend reconciliation' + }, + { + name: 'force', + label: 'Force Apply', + path: 'spec.force', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Force resource updates through delete/recreate if needed' + }, + { + name: 'serviceAccountName', + label: 'Service Account', + path: 'spec.serviceAccountName', + type: 'string', + section: 'advanced', + placeholder: 'kustomize-controller', + description: 'ServiceAccount to impersonate for reconciliation' + }, + { + name: 'retryInterval', + label: 'Retry Interval', + path: 'spec.retryInterval', + type: 'duration', + section: 'advanced', + placeholder: '1m', + description: 'How often to retry after a failure', + validation: DURATION_VALIDATION + }, + { + name: 'images', + label: 'Images', + path: 'spec.images', + type: 'array', + section: 'advanced', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'name', + label: 'Original Name', + path: 'name', + type: 'string', + required: true, + placeholder: 'ghcr.io/stefanprodan/podinfo' + }, + { + name: 'newName', + label: 'New Name', + path: 'newName', + type: 'string', + placeholder: 'registry.example.com/podinfo' + }, + { + name: 'newTag', + label: 'New Tag', + path: 'newTag', + type: 'string', + placeholder: 'v1.0.0' + }, + { + name: 'digest', + label: 'Digest', + path: 'digest', + type: 'string', + placeholder: 'sha256:...' + } + ], + description: 'Override container images' + }, + { + name: 'patches', + label: 'Strategic Merge Patches', + path: 'spec.patches', + type: 'array', + section: 'advanced', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'target', + label: 'Target', + path: 'target', + type: 'textarea', + placeholder: 'group: apps\nversion: v1\nkind: Deployment\nname: my-app' + }, + { + name: 'patch', + label: 'Patch', + path: 'patch', + type: 'textarea', + placeholder: 'apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app' + } + ], + description: 'Strategic merge patches to apply' + }, + { + name: 'components', + label: 'Components', + path: 'spec.components', + type: 'array', + section: 'advanced', + arrayItemType: 'string', + placeholder: './components/feature-a', + description: 'List of Kustomize components' + }, + { + name: 'ignoreMissingComponents', + label: 'Ignore Missing Components', + path: 'spec.ignoreMissingComponents', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Ignore component paths not found in source' + }, + { + name: 'commonMetadataLabels', + label: 'Common Labels', + path: 'spec.commonMetadata.labels', + type: 'textarea', + section: 'customization', + placeholder: 'app: my-app\nenv: prod', + description: 'Labels to apply to all resources (YAML format)' + }, + { + name: 'commonMetadataAnnotations', + label: 'Common Annotations', + path: 'spec.commonMetadata.annotations', + type: 'textarea', + section: 'customization', + placeholder: 'team: frontend', + description: 'Annotations to apply to all resources (YAML format)' + }, + { + name: 'namePrefix', + label: 'Name Prefix', + path: 'spec.namePrefix', + type: 'string', + section: 'customization', + placeholder: 'prod-', + description: 'Prefix to add to all resource names' + }, + { + name: 'nameSuffix', + label: 'Name Suffix', + path: 'spec.nameSuffix', + type: 'string', + section: 'customization', + placeholder: '-v1', + description: 'Suffix to add to all resource names' + }, + { + name: 'postBuildSubstitute', + label: 'Variable Substitution', + path: 'spec.postBuild.substitute', + type: 'textarea', + section: 'customization', + placeholder: 'cluster_name: prod-cluster', + description: + 'Key-value pairs for variable substitution (YAML format). Keys must be valid identifiers (letters, digits, underscores; cannot start with a digit).' + }, + { + name: 'kubeConfigSecret', + label: 'Remote KubeConfig Secret', + path: 'spec.kubeConfig.secretRef.name', + type: 'string', + section: 'remote', + placeholder: 'remote-cluster-kubeconfig', + description: 'Secret containing KubeConfig for remote cluster' + }, + { + name: 'decryptionProvider', + label: 'Decryption Provider', + path: 'spec.decryption.provider', + type: 'select', + section: 'remote', + default: '', + options: [ + { label: 'None', value: '' }, + { label: 'SOPS', value: 'sops' } + ], + description: 'Provider for Secrets decryption' + }, + { + name: 'decryptionSecret', + label: 'Decryption Secret', + path: 'spec.decryption.secretRef.name', + type: 'string', + section: 'remote', + placeholder: 'sops-gpg', + description: 'Secret containing decryption keys', + showIf: { + field: 'decryptionProvider', + value: 'sops' + } + } + ] +}; diff --git a/src/lib/templates/oci-repository.ts b/src/lib/templates/oci-repository.ts new file mode 100644 index 00000000..fa1d9dbb --- /dev/null +++ b/src/lib/templates/oci-repository.ts @@ -0,0 +1,326 @@ +import type { ResourceTemplate } from './types.js'; + +export const OCI_REPOSITORY_TEMPLATE: ResourceTemplate = { + id: 'oci-repository-base', + name: 'OCI Repository', + description: 'Sources from an OCI registry', + kind: 'OCIRepository', + group: 'source.toolkit.fluxcd.io', + version: 'v1', + category: 'sources', + plural: 'ocirepositories', + yaml: `apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: example + namespace: flux-system +spec: + interval: 5m + url: oci://ghcr.io/org/manifests + ref: + tag: v1.0.0`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'source', + title: 'OCI Configuration', + description: 'OCI registry and artifact settings', + defaultExpanded: true + }, + { + id: 'auth', + title: 'Authentication', + description: 'Registry credentials', + collapsible: true, + defaultExpanded: false + }, + { + id: 'advanced', + title: 'Advanced Options', + description: 'Additional configuration options', + collapsible: true, + defaultExpanded: false + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'my-oci-repo', + description: 'Unique name for this OCIRepository resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // OCI Configuration + { + name: 'url', + label: 'Repository URL', + path: 'spec.url', + type: 'string', + required: true, + section: 'source', + placeholder: 'oci://ghcr.io/org/manifests', + description: 'OCI repository URL (must start with oci://)', + validation: { + pattern: '^oci://', + message: 'OCI repository URL must start with oci://' + } + }, + { + name: 'provider', + label: 'OCI Provider', + path: 'spec.provider', + type: 'select', + section: 'source', + default: 'generic', + options: [ + { label: 'Generic', value: 'generic' }, + { label: 'AWS', value: 'aws' }, + { label: 'Azure', value: 'azure' }, + { label: 'GCP', value: 'gcp' } + ], + description: 'Cloud provider for OCI registry authentication' + }, + { + name: 'refType', + label: 'Reference Type', + path: 'spec.ref.type', + type: 'select', + section: 'source', + default: 'tag', + virtual: true, + options: [ + { label: 'Tag', value: 'tag' }, + { label: 'Semver', value: 'semver' }, + { label: 'Digest', value: 'digest' } + ], + description: 'Type of reference to track' + }, + { + name: 'tag', + label: 'Tag', + path: 'spec.ref.tag', + type: 'string', + section: 'source', + placeholder: 'v1.0.0', + description: + "Tag to track. Avoid 'latest' — pin to an explicit version for reproducible deployments.", + showIf: { + field: 'refType', + value: 'tag' + } + }, + { + name: 'semver', + label: 'Semver Range', + path: 'spec.ref.semver', + type: 'string', + section: 'source', + placeholder: '>=1.0.0', + description: 'Semver range to track', + showIf: { + field: 'refType', + value: 'semver' + }, + validation: { + pattern: + '^(?:[<>]=?|=|~|\\^|\\*)?\\s*[0-9]+\\.[0-9]+(?:\\.[0-9]+)?(?:[-+][0-9A-Za-z.-]+)?$', + message: 'Must be a valid semver constraint (e.g., >=1.0.0, ~1.2.0, ^2.0.0)' + } + }, + { + name: 'digest', + label: 'Digest', + path: 'spec.ref.digest', + type: 'string', + section: 'source', + placeholder: 'sha256:abc123...', + description: 'Digest to track', + showIf: { + field: 'refType', + value: 'digest' + } + }, + { + name: 'layerSelector', + label: 'Layer Selector', + path: 'spec.layerSelector', + type: 'object', + section: 'source', + objectFields: [ + { name: 'mediaType', label: 'Media Type', path: 'mediaType', type: 'string' }, + { + name: 'operation', + label: 'Operation', + path: 'operation', + type: 'select', + options: [ + { label: 'Extract', value: 'extract' }, + { label: 'Copy', value: 'copy' } + ] + } + ], + description: 'Select specific OCI layer to extract or copy' + }, + { + name: 'interval', + label: 'Sync Interval', + path: 'spec.interval', + type: 'duration', + required: true, + section: 'source', + default: '5m', + placeholder: '5m', + description: 'How often to check for changes', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: + 'Duration must use time units like: 1m (minutes), 30s (seconds), 1h (hours), or combined like 1h30m' + } + }, + + // Authentication + { + name: 'secretRefName', + label: 'Secret Name', + path: 'spec.secretRef.name', + type: 'string', + section: 'auth', + placeholder: 'oci-credentials', + description: 'Secret containing registry credentials' + }, + { + name: 'serviceAccountName', + label: 'Service Account', + path: 'spec.serviceAccountName', + type: 'string', + section: 'auth', + placeholder: 'oci-puller', + description: 'ServiceAccount for registry authentication' + }, + { + name: 'proxySecretRef', + label: 'Proxy Secret', + path: 'spec.proxySecretRef.name', + type: 'string', + section: 'auth', + placeholder: 'proxy-credentials', + description: 'Secret containing proxy credentials' + }, + + // Advanced Options + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Suspend reconciliation' + }, + { + name: 'timeout', + label: 'Timeout', + path: 'spec.timeout', + type: 'duration', + section: 'advanced', + default: '10m', + placeholder: '60s', + description: 'Timeout for OCI operations', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' + } + }, + { + name: 'ignore', + label: 'Ignore Paths', + path: 'spec.ignore', + type: 'textarea', + section: 'advanced', + placeholder: '# .gitignore format\n*.md', + description: 'Paths to ignore when calculating artifact checksum' + }, + { + name: 'certSecretRef', + label: 'TLS Secret', + path: 'spec.certSecretRef.name', + type: 'string', + section: 'advanced', + placeholder: 'oci-tls-certs', + description: 'Secret containing CA/cert/key for TLS authentication' + }, + { + name: 'insecure', + label: 'Insecure', + path: 'spec.insecure', + type: 'boolean', + section: 'advanced', + default: false, + description: 'Allow insecure connections (skip TLS verification)' + }, + { + name: 'verify', + label: 'Verify', + path: 'spec.verify', + type: 'object', + section: 'advanced', + objectFields: [ + { + name: 'provider', + label: 'Provider', + path: 'provider', + type: 'select', + options: [ + { label: 'Cosign', value: 'cosign' }, + { label: 'Notation', value: 'notation' } + ] + }, + { + name: 'secretRef', + label: 'Secret', + path: 'secretRef', + type: 'object', + objectFields: [ + { + name: 'name', + label: 'Secret Name', + path: 'name', + type: 'string' + } + ] + } + ], + description: 'Signature verification configuration' + } + ] +}; diff --git a/src/lib/templates/provider.ts b/src/lib/templates/provider.ts new file mode 100644 index 00000000..562ab13c --- /dev/null +++ b/src/lib/templates/provider.ts @@ -0,0 +1,203 @@ +import { CEL_VALIDATION, type ResourceTemplate } from './types.js'; + +export const PROVIDER_TEMPLATE: ResourceTemplate = { + id: 'provider-base', + name: 'Provider', + description: 'Configures a notification provider', + kind: 'Provider', + group: 'notification.toolkit.fluxcd.io', + version: 'v1beta3', + category: 'notifications', + plural: 'providers', + yaml: `apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Provider +metadata: + name: slack + namespace: flux-system +spec: + type: slack + channel: general + secretRef: + name: slack-webhook-url`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'provider', + title: 'Provider Configuration', + description: 'Notification provider settings', + defaultExpanded: true + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'slack', + description: 'Unique name for this Provider resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Provider Configuration + { + name: 'type', + label: 'Provider Type', + path: 'spec.type', + type: 'select', + required: true, + section: 'provider', + default: 'slack', + options: [ + { label: 'Slack', value: 'slack' }, + { label: 'Discord', value: 'discord' }, + { label: 'Microsoft Teams', value: 'msteams' }, + { label: 'Rocket', value: 'rocket' }, + { label: 'Google Chat', value: 'googlechat' }, + { label: 'Webex', value: 'webex' }, + { label: 'GitHub', value: 'github' }, + { label: 'GitLab', value: 'gitlab' }, + { label: 'Gitea', value: 'gitea' }, + { label: 'Bitbucket', value: 'bitbucket' }, + { label: 'Bitbucket Server', value: 'bitbucketserver' }, + { label: 'Azure DevOps', value: 'azuredevops' }, + { label: 'Google Pub/Sub', value: 'googlepubsub' }, + { label: 'Generic Webhook', value: 'generic' }, + { label: 'Generic HMAC', value: 'generic-hmac' } + ], + description: 'Type of notification provider' + }, + { + name: 'channel', + label: 'Channel', + path: 'spec.channel', + type: 'string', + section: 'provider', + placeholder: 'general', + description: 'Channel name (for Slack, Discord, etc.)' + }, + { + name: 'username', + label: 'Username', + path: 'spec.username', + type: 'string', + section: 'provider', + placeholder: 'FluxCD Bot', + description: 'Override username for notifications' + }, + { + name: 'secretName', + label: 'Secret Name', + path: 'spec.secretRef.name', + type: 'string', + section: 'provider', + placeholder: 'slack-webhook-url', + description: 'Secret containing webhook URL or credentials (if not using inline address)' + }, + { + name: 'address', + label: 'Address', + path: 'spec.address', + type: 'string', + section: 'provider', + placeholder: 'https://hooks.slack.com/services/...', + description: 'Webhook URL or API address (if not in secret)' + }, + { + name: 'proxy', + label: 'Proxy', + path: 'spec.proxy', + type: 'string', + section: 'provider', + placeholder: 'http://proxy.example.com:8080', + description: 'Proxy address to use for notifications' + }, + { + name: 'tlsCertSecret', + label: 'TLS Certificate Secret', + path: 'spec.certSecretRef.name', + type: 'string', + section: 'provider', + placeholder: 'tls-cert', + description: 'Secret containing TLS certificate' + }, + { + name: 'proxySecretRef', + label: 'Proxy Secret', + path: 'spec.proxySecretRef.name', + type: 'string', + section: 'provider', + placeholder: 'proxy-credentials', + description: 'Secret containing proxy credentials' + }, + { + name: 'timeout', + label: 'Timeout', + path: 'spec.timeout', + type: 'duration', + section: 'provider', + default: '10m', + placeholder: '10m', + description: 'Timeout for sending notifications', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' + } + }, + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'provider', + default: false, + description: 'Suspend notifications' + }, + { + name: 'serviceAccountName', + label: 'Service Account', + path: 'spec.serviceAccountName', + type: 'string', + section: 'provider', + placeholder: 'notification-controller', + description: 'ServiceAccount for cloud provider authentication' + }, + { + name: 'commitStatusExpr', + label: 'Commit Status Expression', + path: 'spec.commitStatusExpr', + type: 'textarea', + section: 'provider', + placeholder: 'event.message', + description: 'CEL expression for custom commit status message', + validation: CEL_VALIDATION + } + ] +}; diff --git a/src/lib/templates/receiver.ts b/src/lib/templates/receiver.ts new file mode 100644 index 00000000..84591987 --- /dev/null +++ b/src/lib/templates/receiver.ts @@ -0,0 +1,179 @@ +import type { ResourceTemplate } from './types.js'; + +export const RECEIVER_TEMPLATE: ResourceTemplate = { + id: 'receiver-base', + name: 'Receiver', + description: 'Webhook receiver for external events', + kind: 'Receiver', + group: 'notification.toolkit.fluxcd.io', + version: 'v1', + category: 'notifications', + plural: 'receivers', + yaml: `apiVersion: notification.toolkit.fluxcd.io/v1 +kind: Receiver +metadata: + name: example + namespace: flux-system +spec: + type: github + events: + - "ping" + - "push" + secretRef: + name: webhook-token + resources: + - kind: GitRepository + name: webapp`, + sections: [ + { + id: 'basic', + title: 'Basic Information', + description: 'Resource identification', + defaultExpanded: true + }, + { + id: 'receiver', + title: 'Receiver Configuration', + description: 'Webhook receiver settings', + defaultExpanded: true + } + ], + fields: [ + // Basic Information + { + name: 'name', + label: 'Name', + path: 'metadata.name', + type: 'string', + required: true, + section: 'basic', + placeholder: 'github-receiver', + description: 'Unique name for this Receiver resource', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Name must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + { + name: 'namespace', + label: 'Namespace', + path: 'metadata.namespace', + type: 'string', + required: true, + section: 'basic', + default: 'flux-system', + description: 'Namespace where the resource will be created', + validation: { + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', + message: + 'Namespace must contain only lowercase letters, numbers, and hyphens (cannot start or end with a hyphen)' + } + }, + + // Receiver Configuration + { + name: 'type', + label: 'Receiver Type', + path: 'spec.type', + type: 'select', + required: true, + section: 'receiver', + default: 'github', + options: [ + { label: 'GitHub', value: 'github' }, + { label: 'GitLab', value: 'gitlab' }, + { label: 'Bitbucket', value: 'bitbucket' }, + { label: 'Harbor', value: 'harbor' }, + { label: 'DockerHub', value: 'dockerhub' }, + { label: 'Quay', value: 'quay' }, + { label: 'Nexus', value: 'nexus' }, + { label: 'ACR', value: 'acr' }, + { label: 'GCR', value: 'gcr' }, + { label: 'CDEvents', value: 'cdevents' }, + { label: 'Generic Webhook', value: 'generic' }, + { label: 'Generic HMAC', value: 'generic-hmac' } + ], + description: 'Type of webhook receiver' + }, + { + name: 'resources', + label: 'Resources', + path: 'spec.resources', + type: 'array', + required: true, + section: 'receiver', + arrayItemType: 'object', + arrayItemFields: [ + { + name: 'kind', + label: 'Kind', + path: 'kind', + type: 'string', + required: true, + placeholder: 'GitRepository', + description: 'Resource kind (e.g., GitRepository, Kustomization)' + }, + { + name: 'name', + label: 'Name', + path: 'name', + type: 'string', + required: true, + placeholder: '* or resource name', + description: 'Resource name; use * to watch all resources of that kind', + referenceTypeField: 'kind', + referenceNamespaceField: 'namespace' + } + ], + placeholder: 'GitRepository', + description: + 'FluxCD resources to reconcile when webhook is triggered. Use * for name to reconcile all resources of that kind.', + helpText: + 'Define which resources should be reconciled when this webhook receives an event. Each entry needs a kind (e.g., GitRepository, HelmRelease) and name (use * for all).', + docsUrl: 'https://fluxcd.io/flux/components/notification/receivers/#resources' + }, + { + name: 'events', + label: 'Events', + path: 'spec.events', + type: 'array', + section: 'receiver', + arrayItemType: 'string', + placeholder: 'push', + description: 'Specific events to receive (if empty, all events are received)' + }, + { + name: 'secretName', + label: 'Secret Name', + path: 'spec.secretRef.name', + type: 'string', + required: true, + section: 'receiver', + placeholder: 'webhook-token', + description: 'Secret containing webhook validation token' + }, + { + name: 'interval', + label: 'Interval', + path: 'spec.interval', + type: 'duration', + section: 'receiver', + placeholder: '10m', + description: 'Reconciliation interval', + validation: { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: 'Duration must be in Flux format (e.g., 60s, 1m30s, 5m)' + } + }, + { + name: 'suspend', + label: 'Suspend', + path: 'spec.suspend', + type: 'boolean', + section: 'receiver', + default: false, + description: 'Suspend webhook processing' + } + ] +}; diff --git a/src/lib/templates/types.ts b/src/lib/templates/types.ts new file mode 100644 index 00000000..55c4df4d --- /dev/null +++ b/src/lib/templates/types.ts @@ -0,0 +1,63 @@ +export interface ResourceTemplate { + id: string; + name: string; + description: string; + kind: string; + group: string; + version: string; + yaml: string; + fields: TemplateField[]; + sections?: TemplateSection[]; + category?: string; // Added for categorization + plural: string; // API plural form (e.g., 'gitrepositories', 'helmcharts') +} + +export interface TemplateField { + name: string; + label: string; + path: string; // JSON path or similar to update the YAML + type: 'string' | 'number' | 'boolean' | 'select' | 'duration' | 'textarea' | 'array' | 'object'; + default?: string | number | boolean | unknown[]; + options?: { label: string; value: string }[]; + required?: boolean; + description?: string; + section?: string; // Section grouping for fields + placeholder?: string; + showIf?: { + field: string; // Name of field to check + value: string | string[]; // Value(s) that trigger visibility + }; + validation?: { + pattern?: string; // Regex pattern + message?: string; // Custom error message + min?: number; // Min value (for numbers) + max?: number; // Max value (for numbers) + }; + arrayItemType?: 'string' | 'object'; // For array fields + arrayItemFields?: TemplateField[]; // For object array items + objectFields?: TemplateField[]; // For object fields + helpText?: string; // Detailed help text for the field + docsUrl?: string; // Link to FluxCD documentation + virtual?: boolean; // UI-only field, do not persist to YAML + referenceType?: string | string[]; // Resource type(s) to autocomplete from + referenceTypeField?: string; // Field to get the reference type from + referenceNamespaceField?: string; // Sibling field to auto-fill with selected namespace +} + +export interface TemplateSection { + id: string; + title: string; + description?: string; + collapsible?: boolean; + defaultExpanded?: boolean; +} + +export const CEL_VALIDATION = { + pattern: '^[\\s\\S]{1,500}$', + message: 'CEL expression must be 500 characters or fewer.' +}; + +export const DURATION_VALIDATION = { + pattern: '^([0-9]+(\\.[0-9]+)?(s|m|h))+$', + message: 'Duration must use Flux time units like 30s, 5m, 1h, or combined values like 1h30m.' +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ac94103f..4e772d6d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -68,15 +68,17 @@ // Connect to SSE when cluster is connected let prevConnected = false; + let prevClusterId = IN_CLUSTER_ID; $effect(() => { const isConnected = data.health.connected; - if (isConnected !== prevConnected) { + const clusterId = data.health.currentClusterId || IN_CLUSTER_ID; + if (isConnected !== prevConnected || clusterId !== prevClusterId) { + eventsStore.disconnect(); if (isConnected) { eventsStore.connect(); - } else { - eventsStore.disconnect(); } prevConnected = isConnected; + prevClusterId = clusterId; } }); diff --git a/src/routes/api/v1/user/cluster/+server.ts b/src/routes/api/v1/user/cluster/+server.ts new file mode 100644 index 00000000..5cdfbd82 --- /dev/null +++ b/src/routes/api/v1/user/cluster/+server.ts @@ -0,0 +1,62 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + clearClusterSelectionCookie, + getClusterSelectionPayload, + setClusterSelectionCookie, + validateSelectableClusterId +} from '$lib/server/clusters/selection.js'; +import { IN_CLUSTER_ID } from '$lib/clusters/identity.js'; + +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + throw error(401, { message: 'Unauthorized', code: 'Unauthorized' }); + } + + const currentClusterId = locals.cluster + ? await validateSelectableClusterId(locals.cluster).catch(() => IN_CLUSTER_ID) + : IN_CLUSTER_ID; + return json(await getClusterSelectionPayload(currentClusterId)); +}; + +export const PUT: RequestHandler = async ({ request, cookies, locals }) => { + if (!locals.user) { + throw error(401, { message: 'Unauthorized', code: 'Unauthorized' }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + throw error(400, { message: 'Invalid JSON', code: 'BadRequest' }); + } + + if ( + typeof rawBody !== 'object' || + rawBody === null || + typeof (rawBody as { clusterId?: unknown }).clusterId !== 'string' + ) { + throw error(400, { message: 'clusterId is required', code: 'BadRequest' }); + } + + let currentClusterId: string; + try { + currentClusterId = await validateSelectableClusterId( + (rawBody as { clusterId: string }).clusterId + ); + } catch { + throw error(404, { message: 'Cluster is not selectable', code: 'NotFound' }); + } + + setClusterSelectionCookie(cookies, currentClusterId); + return json(await getClusterSelectionPayload(currentClusterId)); +}; + +export const DELETE: RequestHandler = async ({ cookies, locals }) => { + if (!locals.user) { + throw error(401, { message: 'Unauthorized', code: 'Unauthorized' }); + } + + clearClusterSelectionCookie(cookies); + return json(await getClusterSelectionPayload(IN_CLUSTER_ID)); +}; diff --git a/src/tests/auth-cookie-regression.test.ts b/src/tests/auth-cookie-regression.test.ts index 3a476023..98c1f8e6 100644 --- a/src/tests/auth-cookie-regression.test.ts +++ b/src/tests/auth-cookie-regression.test.ts @@ -42,7 +42,7 @@ describe('auth source regressions', () => { }); test('does not seed in-cluster admin into an impossible password-change flow', () => { - const source = readRepoFile('lib/server/auth.ts'); + const source = readRepoFile('lib/server/auth/bootstrap-admin.ts'); expect(source).toMatch( /if \(isInClusterMode\(\)\) \{[\s\S]*?requiresPasswordChange: false[\s\S]*?return \{ password, mode: 'in-cluster' \};/s diff --git a/src/tests/cluster-selection-route.test.ts b/src/tests/cluster-selection-route.test.ts new file mode 100644 index 00000000..34cd624a --- /dev/null +++ b/src/tests/cluster-selection-route.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { IN_CLUSTER_ID } from '../lib/clusters/identity.js'; +import { importFresh } from './helpers/import-fresh'; + +let clusterRecord: { + id: string; + name: string; + description: string | null; + isActive: boolean; +} | null; + +function createCookies() { + const values = new Map(); + return { + deleted: [] as Array<{ name: string; options: Record }>, + setCalls: [] as Array<{ name: string; value: string; options: Record }>, + get(name: string) { + return values.get(name); + }, + set(name: string, value: string, options: Record) { + values.set(name, value); + this.setCalls.push({ name, value, options }); + }, + delete(name: string, options: Record) { + values.delete(name); + this.deleted.push({ name, options }); + } + }; +} + +async function importRoute() { + return importFresh( + '../routes/api/v1/user/cluster/+server' + ); +} + +beforeEach(() => { + clusterRecord = { + id: 'cluster-a', + name: 'Cluster A', + description: null, + isActive: true + }; + mock.module('$lib/server/clusters/repository.js', () => ({ + getClusterById: async () => clusterRecord, + getSelectableClusters: async () => [ + { + id: IN_CLUSTER_ID, + name: 'In-cluster', + description: 'Runtime Kubernetes configuration', + source: 'in-cluster', + isActive: true, + currentContext: null + }, + ...(clusterRecord + ? [ + { + id: clusterRecord.id, + name: clusterRecord.name, + description: clusterRecord.description, + source: 'uploaded' as const, + isActive: clusterRecord.isActive, + currentContext: null + } + ] + : []) + ] + })); +}); + +afterEach(() => { + mock.restore(); +}); + +describe('cluster selection route', () => { + test('PUT accepts in-cluster', async () => { + const cookies = createCookies(); + const { PUT } = await importRoute(); + + const response = await PUT({ + cookies, + locals: { user: { id: 'user-1' } }, + request: new Request('http://localhost/api/v1/user/cluster', { + method: 'PUT', + body: JSON.stringify({ clusterId: IN_CLUSTER_ID }) + }) + } as never); + + expect(response.status).toBe(200); + expect(cookies.setCalls[0]).toMatchObject({ name: 'gyre_cluster', value: IN_CLUSTER_ID }); + expect(await response.json()).toMatchObject({ currentClusterId: IN_CLUSTER_ID }); + }); + + test('PUT accepts active uploaded cluster ID', async () => { + const cookies = createCookies(); + const { PUT } = await importRoute(); + + const response = await PUT({ + cookies, + locals: { user: { id: 'user-1' } }, + request: new Request('http://localhost/api/v1/user/cluster', { + method: 'PUT', + body: JSON.stringify({ clusterId: 'cluster-a' }) + }) + } as never); + + expect(response.status).toBe(200); + expect(cookies.setCalls[0]).toMatchObject({ name: 'gyre_cluster', value: 'cluster-a' }); + expect(await response.json()).toMatchObject({ currentClusterId: 'cluster-a' }); + }); + + test('PUT rejects inactive or missing cluster', async () => { + clusterRecord = { id: 'cluster-a', name: 'Cluster A', description: null, isActive: false }; + const { PUT } = await importRoute(); + + await expect( + PUT({ + cookies: createCookies(), + locals: { user: { id: 'user-1' } }, + request: new Request('http://localhost/api/v1/user/cluster', { + method: 'PUT', + body: JSON.stringify({ clusterId: 'cluster-a' }) + }) + } as never) + ).rejects.toMatchObject({ status: 404 }); + }); + + test('DELETE clears selection', async () => { + const cookies = createCookies(); + const { DELETE } = await importRoute(); + + const response = await DELETE({ + cookies, + locals: { user: { id: 'user-1' } } + } as never); + + expect(response.status).toBe(200); + expect(cookies.deleted).toContainEqual({ name: 'gyre_cluster', options: { path: '/' } }); + expect(await response.json()).toMatchObject({ currentClusterId: IN_CLUSTER_ID }); + }); +}); diff --git a/src/tests/events.test.ts b/src/tests/events.test.ts index cffa7f5d..a6b2ec11 100644 --- a/src/tests/events.test.ts +++ b/src/tests/events.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; import * as actualClient from '../lib/server/kubernetes/client.js'; +import * as actualConstants from '../lib/server/config/constants.js'; import * as actualMetrics from '../lib/server/metrics.js'; import { importFresh } from './helpers/import-fresh'; @@ -34,6 +35,7 @@ beforeEach(async () => { captureReconciliation: async () => {} })); mock.module('../lib/server/config/constants.js', () => ({ + ...actualConstants, SETTLING_PERIOD_MS: -1, POLL_INTERVAL_MS: 50, HEARTBEAT_INTERVAL_MS: 10000 diff --git a/src/tests/request-pipeline.test.ts b/src/tests/request-pipeline.test.ts index a2b11ddc..2ebac75b 100644 --- a/src/tests/request-pipeline.test.ts +++ b/src/tests/request-pipeline.test.ts @@ -127,11 +127,12 @@ beforeEach(() => { validateCsrfToken: () => csrfValid })); - mock.module('$lib/server/clusters.js', () => ({ + mock.module('$lib/server/clusters/repository.js', () => ({ getClusterById: async (id: string) => { getClusterByIdCalls.push(id); return clusterRecord; - } + }, + getSelectableClusters: async () => [] })); mock.module('$lib/server/kubernetes/errors.js', () =>