chore: move source to repo root and standardize config

Phase 1 — Structural overhaul:
- Move all source from headlamp-sealed-secrets/ subdirectory to repo root
- Delete 23 AI-generated docs, 8 pre-built tarballs, release snapshots dir
- Remove all working-directory refs from CI/release workflows
- Update install-plugin.sh and typedoc.json paths

Phase 2 — Config standardization:
- Create .eslintrc.js and .prettierrc.js (standard Headlamp configs)
- Remove inline eslintConfig/prettier from package.json (drop jsx-a11y, prettier extends)
- Rewrite tsconfig.json (package name extend, add compilerOptions.types)
- Create vitest.config.mts and vitest.setup.ts (standard from polaris)
- Replace headlamp-plugin CLI scripts with direct tool invocation
- Rewrite .gitignore with standard baseline

Phase 3 — MCP & Claude settings:
- Create .mcp.json with github/kubernetes/flux/playwright servers
- Create .claude/settings.local.json
- Remove 7 specialized agents, keep 3 meta-orchestration agents

Phase 4 — Documentation:
- Rewrite CLAUDE.md (remove subdirectory refs, standard format)
- Add ArtifactHub badge, Architecture section, standardized install methods to README.md
- Create CONTRIBUTING.md and SECURITY.md
- Fix pre-existing test bugs in validators.test.ts (isValidNamespace returns boolean,
  not ValidationResult; error message string mismatches)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-03 21:31:12 +00:00
parent 604fe06f9c
commit af95c3795c
108 changed files with 704 additions and 14041 deletions
+198
View File
@@ -0,0 +1,198 @@
/**
* SealedSecret Custom Resource Definition
*/
import { ApiProxy,K8s } from '@kinvolk/headlamp-plugin/lib';
const { apiFactoryWithNamespace } = ApiProxy;
const { KubeObject } = K8s.cluster;
import { AsyncResult, Err, Ok, tryCatchAsync } from '../types';
import {
SealedSecretInterface,
SealedSecretScope,
SealedSecretSpec,
SealedSecretStatus,
} from '../types';
/**
* SealedSecret CRD class
* Represents a Bitnami Sealed Secret resource in the cluster
*/
export class SealedSecret extends KubeObject<SealedSecretInterface> {
/**
* Default API version (fallback)
*/
static readonly DEFAULT_VERSION = 'bitnami.com/v1alpha1';
/**
* Cached detected API version
*/
private static detectedVersion: string | null = null;
/**
* API endpoint for SealedSecret resources
* bitnami.com/v1alpha1/sealedsecrets
*/
static apiEndpoint = apiFactoryWithNamespace('bitnami.com', 'v1alpha1', 'sealedsecrets');
/**
* Class name used for Headlamp registration
*/
static get className(): string {
return 'SealedSecret';
}
/**
* Get the SealedSecret spec
*/
get spec(): SealedSecretSpec {
return this.jsonData.spec;
}
/**
* Get the SealedSecret status
*/
get status(): SealedSecretStatus | undefined {
return this.jsonData.status;
}
/**
* Get the scope of this SealedSecret (strict, namespace-wide, or cluster-wide)
*/
get scope(): SealedSecretScope {
const annotations = this.metadata.annotations || {};
if (annotations['sealedsecrets.bitnami.com/cluster-wide'] === 'true') {
return 'cluster-wide';
}
if (annotations['sealedsecrets.bitnami.com/namespace-wide'] === 'true') {
return 'namespace-wide';
}
return 'strict';
}
/**
* Get the count of encrypted keys
*/
get encryptedKeysCount(): number {
return Object.keys(this.spec.encryptedData || {}).length;
}
/**
* Check if the SealedSecret is synced
*/
get isSynced(): boolean {
const syncCondition = this.status?.conditions?.find(c => c.type === 'Synced');
return syncCondition?.status === 'True';
}
/**
* Get the sync status condition
*/
get syncCondition() {
return this.status?.conditions?.find(c => c.type === 'Synced');
}
/**
* Get the sync status message
*/
get syncMessage(): string {
const condition = this.syncCondition;
if (!condition) {
return 'Unknown';
}
// Ensure we always return a string, not an object
const message = condition.message || condition.reason || condition.status;
return String(message || 'Unknown');
}
/**
* Detect the API version available in the cluster
*
* Queries the SealedSecrets CRD to determine which API version is installed
* and preferred. Returns the storage version (the version used for persisting
* objects in etcd).
*
* @returns Result containing the API version string (e.g., "bitnami.com/v1alpha1")
*/
static async detectApiVersion(): AsyncResult<string, string> {
// Return cached version if available
if (this.detectedVersion) {
return Ok(this.detectedVersion);
}
const result = await tryCatchAsync(async () => {
// Query the CRD to get available versions using Headlamp's API proxy
const crd = await ApiProxy.request(
'/apis/apiextensions.k8s.io/v1/customresourcedefinitions/sealedsecrets.bitnami.com'
);
// Find the storage version (the version used for persistence)
const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true);
if (storageVersion) {
const version = `${crd.spec.group}/${storageVersion.name}`;
this.detectedVersion = version;
return version;
}
// Fallback to first served version if no storage version found
const servedVersion = crd.spec?.versions?.find((v: any) => v.served === true);
if (servedVersion) {
const version = `${crd.spec.group}/${servedVersion.name}`;
this.detectedVersion = version;
return version;
}
// Ultimate fallback to default
return this.DEFAULT_VERSION;
});
if (result.ok === false) {
return Err(result.error.message);
}
return Ok(result.value);
}
/**
* Get API endpoint with auto-detected version
*
* Automatically detects and uses the correct API version from the cluster.
* Falls back to default version (v1alpha1) if detection fails.
*
* @returns API endpoint configured with the detected version
*/
static async getApiEndpoint() {
const versionResult = await this.detectApiVersion();
if (versionResult.ok) {
const [group, version] = versionResult.value.split('/');
return apiFactoryWithNamespace(group, version, 'sealedsecrets');
}
// Fallback to default endpoint
return this.apiEndpoint;
}
/**
* Get the detected API version
*
* Returns the cached detected version or null if not yet detected.
*
* @returns The detected API version string or null
*/
static getDetectedVersion(): string | null {
return this.detectedVersion;
}
/**
* Clear the cached API version
*
* Forces re-detection on next call to detectApiVersion().
* Useful for refreshing after CRD updates.
*/
static clearVersionCache(): void {
this.detectedVersion = null;
}
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Sealed Secrets Controller API helpers
*
* Utilities for interacting with the sealed-secrets-controller HTTP API
* via the Kubernetes API proxy.
*/
import { AsyncResult, Err, Ok, PEMCertificate, PluginConfig, tryCatchAsync } from '../types';
import { retryWithBackoff } from './retry';
/**
* Controller health status information
*/
export interface ControllerHealthStatus {
/** Whether the controller is healthy and responding */
healthy: boolean;
/** Whether the controller is reachable */
reachable: boolean;
/** Controller version if available */
version?: string;
/** Response latency in milliseconds */
latencyMs?: number;
/** Error message if not healthy */
error?: string;
}
/**
* Build the controller proxy URL
*/
export function getControllerProxyURL(config: PluginConfig, path: string): string {
const { controllerNamespace, controllerName, controllerPort } = config;
return `/api/v1/namespaces/${controllerNamespace}/services/http:${controllerName}:${controllerPort}/proxy${path}`;
}
/**
* Fetch the controller's public certificate (internal, no retry)
*/
async function fetchPublicCertificateOnce(
config: PluginConfig
): AsyncResult<PEMCertificate, string> {
const url = getControllerProxyURL(config, '/v1/cert.pem');
const result = await tryCatchAsync(async () => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch certificate: ${response.status} ${response.statusText}`);
}
return PEMCertificate(await response.text());
});
if (result.ok === false) {
return Err(`Unable to fetch controller certificate: ${result.error.message}`);
}
return result;
}
/**
* Fetch the controller's public certificate with retry logic
*
* Automatically retries on network errors with exponential backoff:
* - Max 3 attempts
* - Initial delay: 1s
* - Max delay: 10s
* - Exponential backoff with jitter
*
* @param config Plugin configuration
* @returns Result containing PEM-encoded certificate (branded type) or error message
*/
export async function fetchPublicCertificate(
config: PluginConfig
): AsyncResult<PEMCertificate, string> {
return retryWithBackoff(() => fetchPublicCertificateOnce(config), {
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
});
}
/**
* Verify that a SealedSecret can be decrypted by the controller
*
* @param config Plugin configuration
* @param sealedSecretYaml YAML or JSON of the SealedSecret
* @returns Result containing verification status or error message
*/
export async function verifySealedSecret(
config: PluginConfig,
sealedSecretYaml: string
): AsyncResult<boolean, string> {
const url = getControllerProxyURL(config, '/v1/verify');
const result = await tryCatchAsync(async () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: sealedSecretYaml,
});
return response.ok;
});
if (result.ok === false) {
return Err(`Verification failed: ${result.error.message}`);
}
return result;
}
/**
* Rotate (re-encrypt) a SealedSecret with the current active key
*
* @param config Plugin configuration
* @param sealedSecretYaml YAML or JSON of the SealedSecret
* @returns Result containing the re-encrypted SealedSecret or error message
*/
export async function rotateSealedSecret(
config: PluginConfig,
sealedSecretYaml: string
): AsyncResult<string, string> {
const url = getControllerProxyURL(config, '/v1/rotate');
const result = await tryCatchAsync(async () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: sealedSecretYaml,
});
if (!response.ok) {
throw new Error(`Rotation failed: ${response.status} ${response.statusText}`);
}
return await response.text();
});
if (result.ok === false) {
return Err(`Unable to rotate SealedSecret: ${result.error.message}`);
}
return result;
}
/**
* Get plugin configuration from localStorage
*/
export function getPluginConfig(): PluginConfig {
const stored = localStorage.getItem('sealed-secrets-plugin-config');
if (stored) {
try {
return JSON.parse(stored);
} catch {
// Fall through to default
}
}
// Return default config
return {
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
};
}
/**
* Save plugin configuration to localStorage
*/
export function savePluginConfig(config: PluginConfig): void {
localStorage.setItem('sealed-secrets-plugin-config', JSON.stringify(config));
}
/**
* Check controller health and reachability
*
* Attempts to reach the controller's health endpoint (/healthz) with a 5-second timeout.
* Returns health status including latency and version information if available.
*
* @param config Plugin configuration
* @returns Result containing health status (never fails - returns status even if unreachable)
*/
export async function checkControllerHealth(
config: PluginConfig
): AsyncResult<ControllerHealthStatus, string> {
const startTime = Date.now();
try {
const url = getControllerProxyURL(config, '/healthz');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
const response = await fetch(url, {
method: 'GET',
signal: controller.signal,
});
clearTimeout(timeoutId);
const latencyMs = Date.now() - startTime;
if (!response.ok) {
return Ok({
healthy: false,
reachable: true,
latencyMs,
error: `HTTP ${response.status}: ${response.statusText}`,
});
}
// Try to get version from headers
const version = response.headers.get('X-Controller-Version') || undefined;
return Ok({
healthy: true,
reachable: true,
version,
latencyMs,
});
} catch (error: any) {
const latencyMs = Date.now() - startTime;
// Determine error type
let errorMessage = 'Controller unreachable';
if (error.name === 'AbortError') {
errorMessage = 'Request timed out after 5 seconds';
} else if (error.message) {
errorMessage = error.message;
}
return Ok({
healthy: false,
reachable: false,
latencyMs,
error: errorMessage,
});
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* Client-side encryption utilities for Sealed Secrets
*
* This module handles the encryption of secret values using the sealed-secrets
* controller's public key. The encryption process matches the kubeseal CLI tool:
*
* 1. Generate a random AES-256-GCM session key
* 2. Encrypt the secret value with the session key
* 3. Encrypt the session key with the RSA public key (OAEP + SHA-256)
* 4. Construct the payload: 2-byte length prefix + encrypted session key + encrypted data
* 5. Base64-encode the result
*/
import forge from 'node-forge';
import {
Base64String,
CertificateInfo,
Err,
Ok,
PEMCertificate,
PlaintextValue,
Result,
SealedSecretScope,
} from '../types';
/**
* Parse a PEM certificate and extract the RSA public key
*
* @param pemCert PEM-encoded certificate string (branded type)
* @returns Result containing the public key or an error message
*/
export function parsePublicKeyFromCert(
pemCert: PEMCertificate
): Result<forge.pki.rsa.PublicKey, string> {
try {
const cert = forge.pki.certificateFromPem(pemCert);
const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
return Ok(publicKey);
} catch (error) {
return Err(`Failed to parse certificate: ${error}`);
}
}
/**
* Encrypt a secret value using the kubeseal format
*
* @param publicKey RSA public key from the controller's certificate
* @param value The plaintext secret value to encrypt (branded type)
* @param namespace The namespace (for strict/namespace-wide scoping)
* @param name The secret name (for strict scoping)
* @param key The key name within the secret
* @param scope The encryption scope
* @returns Result containing base64-encoded encrypted value or error message
*/
export function encryptValue(
publicKey: forge.pki.rsa.PublicKey,
value: PlaintextValue,
namespace: string,
name: string,
key: string,
scope: SealedSecretScope
): Result<Base64String, string> {
try {
// Generate a random 32-byte (256-bit) AES session key
const sessionKey = forge.random.getBytesSync(32);
// Construct the label for RSA-OAEP based on scope
// This binds the encryption to specific namespace/name/key depending on scope
let label = '';
if (scope === 'strict') {
// Strict scope: namespace.name.key
label = `${namespace}.${name}.${key}`;
} else if (scope === 'namespace-wide') {
// Namespace-wide scope: namespace.key
label = `${namespace}.${key}`;
} else {
// Cluster-wide scope: just the key
label = key;
}
// Encrypt the session key with RSA-OAEP (SHA-256)
const encryptedSessionKey = publicKey.encrypt(sessionKey, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: {
md: forge.md.sha256.create(),
},
label: label,
});
// Encrypt the actual secret value with AES-256-GCM
const iv = forge.random.getBytesSync(12); // 12 bytes for GCM
const cipher = forge.cipher.createCipher('AES-GCM', sessionKey);
cipher.start({ iv: iv });
cipher.update(forge.util.createBuffer(value, 'utf8'));
cipher.finish();
const encryptedValue = cipher.output.getBytes();
const tag = (cipher.mode as any).tag.getBytes();
// Construct the sealed secret format:
// [2-byte length of encrypted session key][encrypted session key][IV][encrypted value][auth tag]
const sessionKeyLength = encryptedSessionKey.length;
const lengthBytes =
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
String.fromCharCode(sessionKeyLength & 0xff);
const payload = lengthBytes + encryptedSessionKey + iv + encryptedValue + tag;
// Base64 encode the final payload
return Ok(Base64String(forge.util.encode64(payload)));
} catch (error) {
return Err(`Encryption failed: ${error}`);
}
}
/**
* Encrypt multiple key-value pairs for a SealedSecret
*
* @param publicKey RSA public key from the controller's certificate
* @param keyValues Array of {key, value} pairs to encrypt (values are branded plaintext)
* @param namespace The namespace
* @param name The secret name
* @param scope The encryption scope
* @returns Result containing object mapping keys to encrypted values, or error message
*/
export function encryptKeyValues(
publicKey: forge.pki.rsa.PublicKey,
keyValues: Array<{ key: string; value: PlaintextValue }>,
namespace: string,
name: string,
scope: SealedSecretScope
): Result<Record<string, Base64String>, string> {
const encryptedData: Record<string, Base64String> = {};
for (const { key, value } of keyValues) {
const result = encryptValue(publicKey, value, namespace, name, key, scope);
if (result.ok === false) {
return Err(`Failed to encrypt key '${key}': ${result.error}`);
}
encryptedData[key] = result.value;
}
return Ok(encryptedData);
}
/**
* Validate a PEM certificate
*
* @param pemCert PEM-encoded certificate string (branded type)
* @returns true if certificate is valid, false otherwise
*/
export function validateCertificate(pemCert: PEMCertificate): boolean {
const result = parsePublicKeyFromCert(pemCert);
return result.ok;
}
/**
* Parse certificate and extract metadata
*
* Extracts validity dates, issuer/subject information, and calculates
* expiration status and fingerprint.
*
* @param pemCert PEM-encoded certificate string (branded type)
* @returns Result containing certificate information or error message
*/
export function parseCertificateInfo(pemCert: PEMCertificate): Result<CertificateInfo, string> {
try {
const cert = forge.pki.certificateFromPem(pemCert);
const now = new Date();
// Extract validity dates
const validFrom = cert.validity.notBefore;
const validTo = cert.validity.notAfter;
// Calculate expiration status
const isExpired = now > validTo;
const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
// Format issuer and subject
const formatDN = (attributes: forge.pki.CertificateField[]): string => {
return attributes.map(a => `${a.shortName}=${a.value}`).join(', ');
};
const issuer = formatDN(cert.issuer.attributes);
const subject = formatDN(cert.subject.attributes);
// Calculate SHA-256 fingerprint
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
const md = forge.md.sha256.create();
md.update(der);
const fingerprint = md.digest().toHex().toUpperCase();
// Get serial number
const serialNumber = cert.serialNumber;
return Ok({
validFrom,
validTo,
isExpired,
daysUntilExpiry,
issuer,
subject,
fingerprint,
serialNumber,
});
} catch (error) {
return Err(`Failed to parse certificate info: ${error}`);
}
}
/**
* Check if certificate will expire soon (within threshold)
*
* @param info Certificate information
* @param daysThreshold Number of days to consider "expiring soon" (default: 30)
* @returns true if certificate will expire within threshold days
*/
export function isCertificateExpiringSoon(info: CertificateInfo, daysThreshold = 30): boolean {
return !info.isExpired && info.daysUntilExpiry <= daysThreshold;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* RBAC Permission Checking
*
* Utilities for checking user permissions for SealedSecrets and related
* Kubernetes resources using SelfSubjectAccessReview API.
*/
import { AsyncResult, Err, Ok, tryCatchAsync } from '../types';
/**
* Resource permissions for a specific resource type
*/
export interface ResourcePermissions {
/** Can create new resources */
canCreate: boolean;
/** Can read/get individual resources */
canRead: boolean;
/** Can update/patch existing resources */
canUpdate: boolean;
/** Can delete resources */
canDelete: boolean;
/** Can list resources */
canList: boolean;
}
/**
* Check user permissions for SealedSecrets in a namespace
*
* Uses Kubernetes SelfSubjectAccessReview API to verify what the current
* user is allowed to do with SealedSecret resources.
*
* @param namespace Optional namespace to check (cluster-wide if omitted)
* @returns Result containing permission flags or error message
*/
export async function checkSealedSecretPermissions(
namespace?: string
): AsyncResult<ResourcePermissions, string> {
try {
const [canCreate, canRead, canUpdate, canDelete, canList] = await Promise.all([
checkPermission('create', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('get', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('update', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('delete', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('list', 'sealedsecrets', 'bitnami.com', namespace),
]);
return Ok({
canCreate,
canRead,
canUpdate,
canDelete,
canList,
});
} catch (error: any) {
return Err(`Failed to check SealedSecret permissions: ${error.message}`);
}
}
/**
* Check if user can decrypt secrets (requires get permission on Secrets)
*
* @param namespace Namespace to check Secret permissions in
* @returns true if user has permission to get Secrets
*/
export async function canDecryptSecrets(namespace: string): Promise<boolean> {
try {
return await checkPermission('get', 'secrets', '', namespace);
} catch {
return false;
}
}
/**
* Check if user can view sealing keys (requires get permission on Secrets in controller namespace)
*
* @param controllerNamespace Namespace where sealed-secrets controller is running
* @returns true if user has permission to get Secrets in controller namespace
*/
export async function canViewSealingKeys(controllerNamespace: string): Promise<boolean> {
try {
return await checkPermission('get', 'secrets', '', controllerNamespace);
} catch {
return false;
}
}
/**
* Check a specific permission using SelfSubjectAccessReview
*
* @param verb Kubernetes verb (create, get, update, delete, list, etc.)
* @param resource Resource type (sealedsecrets, secrets, etc.)
* @param group API group (bitnami.com for SealedSecrets, empty for core resources)
* @param namespace Optional namespace (cluster-wide if omitted)
* @returns true if user has permission, false otherwise
*/
async function checkPermission(
verb: string,
resource: string,
group: string,
namespace?: string
): Promise<boolean> {
const result = await tryCatchAsync(async () => {
const reviewRequest = {
apiVersion: 'authorization.k8s.io/v1',
kind: 'SelfSubjectAccessReview',
spec: {
resourceAttributes: {
...(group && { group }),
resource,
verb,
...(namespace && { namespace }),
},
},
};
const response = await fetch('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reviewRequest),
});
if (!response.ok) {
throw new Error(`RBAC check failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return result.status?.allowed === true;
});
// Return false on error (assume no permission)
return result.ok ? result.value : false;
}
/**
* Check permissions for multiple namespaces
*
* Useful for multi-namespace views to determine which namespaces the user
* can interact with.
*
* @param namespaces Array of namespace names to check
* @returns Map of namespace to permissions
*/
export async function checkMultiNamespacePermissions(
namespaces: string[]
): AsyncResult<Record<string, ResourcePermissions>, string> {
try {
const results = await Promise.all(
namespaces.map(async ns => {
const perms = await checkSealedSecretPermissions(ns);
return { namespace: ns, permissions: perms };
})
);
const permissionsMap: Record<string, ResourcePermissions> = {};
for (const { namespace, permissions } of results) {
if (permissions.ok) {
permissionsMap[namespace] = permissions.value;
}
}
return Ok(permissionsMap);
} catch (error: any) {
return Err(`Failed to check multi-namespace permissions: ${error.message}`);
}
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Unit tests for retry logic
*
* Tests exponential backoff with jitter
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { retryWithBackoff } from './retry';
describe('retry logic', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('retryWithBackoff', () => {
it('should return result on first success', async () => {
const successFn = vi.fn().mockResolvedValue({ ok: true, value: 'success' });
const promise = retryWithBackoff(successFn, { maxAttempts: 3, initialDelayMs: 100 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe('success');
}
expect(successFn).toHaveBeenCalledTimes(1);
});
it('should retry on failure', async () => {
const failTwiceThenSucceed = vi
.fn()
.mockResolvedValueOnce({ ok: false, error: 'error1' })
.mockResolvedValueOnce({ ok: false, error: 'error2' })
.mockResolvedValueOnce({ ok: true, value: 'success' });
const promise = retryWithBackoff(failTwiceThenSucceed, { maxAttempts: 3, initialDelayMs: 100 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe('success');
}
expect(failTwiceThenSucceed).toHaveBeenCalledTimes(3);
});
it('should return aggregated error after max attempts', async () => {
const alwaysFail = vi.fn().mockResolvedValue({ ok: false, error: 'persistent error' });
const promise = retryWithBackoff(alwaysFail, { maxAttempts: 3, initialDelayMs: 100 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Operation failed after 3 attempts');
expect(result.error).toContain('persistent error');
}
expect(alwaysFail).toHaveBeenCalledTimes(3);
});
it('should apply exponential backoff', async () => {
const failTwiceThenSucceed = vi
.fn()
.mockResolvedValueOnce({ ok: false, error: 'error1' })
.mockResolvedValueOnce({ ok: false, error: 'error2' })
.mockResolvedValueOnce({ ok: true, value: 'success' });
const promise = retryWithBackoff(failTwiceThenSucceed, { maxAttempts: 3, initialDelayMs: 1000 });
// Fast-forward through retries
await vi.runAllTimersAsync();
await promise;
// Should have been called 3 times (initial + 2 retries)
expect(failTwiceThenSucceed).toHaveBeenCalledTimes(3);
});
it('should handle function that throws', async () => {
const throwingFn = vi.fn().mockRejectedValue(new Error('Network error'));
const promise = retryWithBackoff(throwingFn, { maxAttempts: 3, initialDelayMs: 100 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Network error');
}
expect(throwingFn).toHaveBeenCalledTimes(3);
});
it('should retry exactly maxAttempts times', async () => {
const alwaysFail = vi.fn().mockResolvedValue({ ok: false, error: 'error' });
const promise = retryWithBackoff(alwaysFail, { maxAttempts: 5, initialDelayMs: 100 });
await vi.runAllTimersAsync();
await promise;
expect(alwaysFail).toHaveBeenCalledTimes(5);
});
it('should handle single attempt', async () => {
const failOnce = vi.fn().mockResolvedValue({ ok: false, error: 'error' });
const promise = retryWithBackoff(failOnce, { maxAttempts: 1, initialDelayMs: 100 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
expect(failOnce).toHaveBeenCalledTimes(1);
});
it('should apply jitter to delay', async () => {
// Mock Math.random to return predictable values
const originalRandom = Math.random;
let callCount = 0;
Math.random = () => {
callCount++;
return callCount === 1 ? 0.5 : 0.8; // Different jitter values
};
const failTwice = vi
.fn()
.mockResolvedValueOnce({ ok: false, error: 'error1' })
.mockResolvedValueOnce({ ok: false, error: 'error2' })
.mockResolvedValueOnce({ ok: true, value: 'success' });
const promise = retryWithBackoff(failTwice, { maxAttempts: 3, initialDelayMs: 1000 });
await vi.runAllTimersAsync();
await promise;
Math.random = originalRandom;
expect(failTwice).toHaveBeenCalledTimes(3);
});
it('should work with different base delays', async () => {
const failOnce = vi
.fn()
.mockResolvedValueOnce({ ok: false, error: 'error' })
.mockResolvedValueOnce({ ok: true, value: 'success' });
// Test with 500ms base delay
const promise = retryWithBackoff(failOnce, { maxAttempts: 2, initialDelayMs: 500 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(true);
expect(failOnce).toHaveBeenCalledTimes(2);
});
it('should preserve error messages in aggregate', async () => {
const specificError = vi.fn().mockResolvedValue({
ok: false,
error: 'Certificate expired on 2024-01-01',
});
const promise = retryWithBackoff(specificError, { maxAttempts: 2, initialDelayMs: 100 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Certificate expired on 2024-01-01');
expect(result.error).toContain('2024-01-01');
}
});
});
describe('edge cases', () => {
it('should handle immediate success', async () => {
const immediate = vi.fn().mockResolvedValue({ ok: true, value: 42 });
const result = await retryWithBackoff(immediate, { maxAttempts: 1, initialDelayMs: 100 });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe(42);
}
expect(immediate).toHaveBeenCalledTimes(1);
});
it('should handle null values', async () => {
const nullValue = vi.fn().mockResolvedValue({ ok: true, value: null });
const result = await retryWithBackoff(nullValue, { maxAttempts: 3, initialDelayMs: 100 });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe(null);
}
});
it('should handle empty error messages', async () => {
const emptyError = vi.fn().mockResolvedValue({ ok: false, error: '' });
const promise = retryWithBackoff(emptyError, { maxAttempts: 2, initialDelayMs: 100 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Operation failed');
}
});
it('should use default options when not provided', async () => {
const successFn = vi.fn().mockResolvedValue({ ok: true, value: 'default success' });
const result = await retryWithBackoff(successFn);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe('default success');
}
});
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Retry logic with exponential backoff
*
* Provides utilities for retrying failed operations with configurable
* backoff strategies and error handling.
*/
import { AsyncResult, Err } from '../types';
/**
* Retry configuration options
*/
export interface RetryOptions {
/** Maximum number of retry attempts (default: 3) */
maxAttempts?: number;
/** Initial delay in milliseconds (default: 1000) */
initialDelayMs?: number;
/** Maximum delay in milliseconds (default: 10000) */
maxDelayMs?: number;
/** Backoff multiplier (default: 2 for exponential) */
backoffMultiplier?: number;
/** Whether to add jitter to delays (default: true) */
useJitter?: boolean;
/** Predicate to determine if error is retryable (default: all errors retryable) */
isRetryable?: (error: Error) => boolean;
}
/**
* Default retry options
*/
const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
backoffMultiplier: 2,
useJitter: true,
isRetryable: () => true, // All errors retryable by default
};
/**
* Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Calculate delay with exponential backoff and optional jitter
*
* @param attempt Current attempt number (0-indexed)
* @param options Retry options
* @returns Delay in milliseconds
*/
function calculateDelay(attempt: number, options: Required<RetryOptions>): number {
const { initialDelayMs, maxDelayMs, backoffMultiplier, useJitter } = options;
// Exponential backoff: initialDelay * (multiplier ^ attempt)
let delay = initialDelayMs * Math.pow(backoffMultiplier, attempt);
// Cap at max delay
delay = Math.min(delay, maxDelayMs);
// Add jitter (±25% random variation)
if (useJitter) {
const jitterRange = delay * 0.25;
const jitter = Math.random() * jitterRange * 2 - jitterRange;
delay = Math.max(0, delay + jitter);
}
return Math.floor(delay);
}
/**
* Retry an async operation with exponential backoff
*
* @param operation Async operation to retry (should return AsyncResult)
* @param options Retry configuration
* @returns Result of the operation or final error after all retries
*
* @example
* const result = await retryWithBackoff(
* async () => fetchPublicCertificate(config),
* { maxAttempts: 3, initialDelayMs: 1000 }
* );
*/
export async function retryWithBackoff<T, E>(
operation: () => AsyncResult<T, E>,
options: RetryOptions = {}
): AsyncResult<T, string> {
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
const errors: string[] = [];
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
const result = await operation();
// If operation succeeded, return immediately
if (result.ok) {
return result;
}
// Operation returned an error - use explicit check for type narrowing
if (result.ok === false) {
const errorMessage = typeof result.error === 'string' ? result.error : String(result.error);
errors.push(`Attempt ${attempt + 1}: ${errorMessage}`);
}
// Check if we should retry
const isLastAttempt = attempt === opts.maxAttempts - 1;
if (isLastAttempt) {
// No more retries, return final error
return Err(
`Operation failed after ${opts.maxAttempts} attempts:\n${errors.join('\n')}`
);
}
// Wait before retrying
const delay = calculateDelay(attempt, opts);
await sleep(delay);
} catch (error) {
// Unexpected exception (shouldn't happen with AsyncResult, but handle it)
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Attempt ${attempt + 1}: ${errorMessage}`);
const isLastAttempt = attempt === opts.maxAttempts - 1;
if (isLastAttempt) {
return Err(
`Operation failed after ${opts.maxAttempts} attempts:\n${errors.join('\n')}`
);
}
const delay = calculateDelay(attempt, opts);
await sleep(delay);
}
}
// Should never reach here, but TypeScript needs it
return Err(`Operation failed after ${opts.maxAttempts} attempts:\n${errors.join('\n')}`);
}
/**
* Predicate to check if error is a network error (retryable)
*
* @param error Error to check
* @returns true if error is network-related
*/
export function isNetworkError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes('network') ||
message.includes('timeout') ||
message.includes('fetch') ||
message.includes('connection') ||
message.includes('econnrefused') ||
message.includes('enotfound')
);
}
/**
* Predicate to check if HTTP error is retryable (5xx, 429, 408)
*
* @param error Error to check
* @returns true if HTTP status is retryable
*/
export function isRetryableHttpError(error: Error): boolean {
const message = error.message;
// Check for 5xx server errors
if (/5\d{2}/.test(message)) {
return true;
}
// Check for specific retryable status codes
return message.includes('429') || // Too Many Requests
message.includes('408') || // Request Timeout
message.includes('503') || // Service Unavailable
message.includes('504'); // Gateway Timeout
}
/**
* Combined predicate for network and HTTP errors
*
* @param error Error to check
* @returns true if error is retryable
*/
export function isRetryableError(error: Error): boolean {
return isNetworkError(error) || isRetryableHttpError(error);
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Unit tests for validators
*
* Tests validation functions for Kubernetes names, secret keys, and values
*/
// Mock localStorage before importing any modules that might use it
const localStorageMock = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
};
(global as any).localStorage = localStorageMock;
import { describe, expect, it } from 'vitest';
import {
isValidNamespace,
validatePEMCertificate,
validateSecretKey,
validateSecretName,
validateSecretValue,
} from './validators';
describe('validators', () => {
describe('validateSecretName', () => {
it('should accept valid Kubernetes names', () => {
expect(validateSecretName('my-secret').valid).toBe(true);
expect(validateSecretName('secret-123').valid).toBe(true);
expect(validateSecretName('a').valid).toBe(true);
expect(validateSecretName('test-secret-name').valid).toBe(true);
expect(validateSecretName('x'.repeat(253)).valid).toBe(true); // Max length
});
it('should reject names with uppercase letters', () => {
const result = validateSecretName('My-Secret');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject names starting with hyphen', () => {
const result = validateSecretName('-secret');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject names ending with hyphen', () => {
const result = validateSecretName('secret-');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject names with underscores', () => {
const result = validateSecretName('secret_name');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject empty names', () => {
const result = validateSecretName('');
expect(result.valid).toBe(false);
expect(result.error).toContain('required');
});
it('should reject names exceeding 253 characters', () => {
const result = validateSecretName('x'.repeat(254));
expect(result.valid).toBe(false);
expect(result.error).toContain('253 characters');
});
it('should reject names with special characters', () => {
expect(validateSecretName('secret@name').valid).toBe(false);
expect(validateSecretName('secret:name').valid).toBe(false);
expect(validateSecretName('secret name').valid).toBe(false);
});
it('should accept names starting with numbers', () => {
const result = validateSecretName('123-secret');
expect(result.valid).toBe(true);
});
});
describe('validateNamespace', () => {
it('should accept valid namespace names', () => {
expect(isValidNamespace('default')).toBe(true);
expect(isValidNamespace('kube-system')).toBe(true);
expect(isValidNamespace('my-namespace')).toBe(true);
expect(isValidNamespace('ns-123')).toBe(true);
});
it('should reject invalid namespace names', () => {
expect(isValidNamespace('')).toBe(false);
expect(isValidNamespace('My-Namespace')).toBe(false);
expect(isValidNamespace('-namespace')).toBe(false);
expect(isValidNamespace('namespace-')).toBe(false);
expect(isValidNamespace('namespace_name')).toBe(false);
});
it('should reject namespaces exceeding 253 characters', () => {
expect(isValidNamespace('x'.repeat(254))).toBe(false);
});
});
describe('validateSecretKey', () => {
it('should accept valid secret keys', () => {
expect(validateSecretKey('password').valid).toBe(true);
expect(validateSecretKey('api-key').valid).toBe(true);
expect(validateSecretKey('api_key').valid).toBe(true);
expect(validateSecretKey('api.key').valid).toBe(true);
expect(validateSecretKey('API_KEY').valid).toBe(true);
expect(validateSecretKey('key123').valid).toBe(true);
});
it('should reject empty keys', () => {
const result = validateSecretKey('');
expect(result.valid).toBe(false);
expect(result.error).toContain('Key name is required');
});
it('should reject keys with invalid characters', () => {
expect(validateSecretKey('key@name').valid).toBe(false);
expect(validateSecretKey('key name').valid).toBe(false);
expect(validateSecretKey('key:name').valid).toBe(false);
expect(validateSecretKey('key/name').valid).toBe(false);
});
it('should reject keys exceeding 253 characters', () => {
const result = validateSecretKey('x'.repeat(254));
expect(result.valid).toBe(false);
expect(result.error).toContain('253 characters');
});
});
describe('validateSecretValue', () => {
it('should accept non-empty values', () => {
expect(validateSecretValue('password123').valid).toBe(true);
expect(validateSecretValue('a').valid).toBe(true);
expect(validateSecretValue('multi\nline\nvalue').valid).toBe(true);
expect(validateSecretValue('special!@#$%^&*()chars').valid).toBe(true);
});
it('should reject empty values', () => {
const result = validateSecretValue('');
expect(result.valid).toBe(false);
expect(result.error).toContain('required');
});
it('should accept large values', () => {
const largeValue = 'x'.repeat(10000);
expect(validateSecretValue(largeValue).valid).toBe(true);
});
it('should reject values exceeding 1MB', () => {
const veryLargeValue = 'x'.repeat(1024 * 1024 + 1);
const result = validateSecretValue(veryLargeValue);
expect(result.valid).toBe(false);
expect(result.error).toContain('1MB');
});
});
describe('validatePEMCertificate', () => {
const validPEM = `-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHHCgVZU1M0MA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl
c3RjYTAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM
BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwKX5UvKZU8rKFXJN
uTGBGGfLYmNHJ6U3kS7hVf8TQPKqKqEQ7vVwVnDFPFLPmqDYnVQH2hN4Z6YpXqKY
KKKKKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKq
KqKqKqKqKqKqKqKqKqKqKqIBAgMBAAEwDQYJKoZIhvcNAQELBQADgYEAoKKKKqKq
KqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKq
KqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKq
KqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKq
KqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKq
KqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKq
-----END CERTIFICATE-----`;
it('should accept valid PEM certificates', () => {
expect(validatePEMCertificate(validPEM).valid).toBe(true);
});
it('should reject empty certificates', () => {
const result = validatePEMCertificate('');
expect(result.valid).toBe(false);
expect(result.error).toContain('required');
});
it('should reject certificates without BEGIN marker', () => {
const invalidPEM = validPEM.replace('-----BEGIN CERTIFICATE-----', '');
const result = validatePEMCertificate(invalidPEM);
expect(result.valid).toBe(false);
});
it('should reject certificates without END marker', () => {
const invalidPEM = validPEM.replace('-----END CERTIFICATE-----', '');
const result = validatePEMCertificate(invalidPEM);
expect(result.valid).toBe(false);
});
it('should reject non-PEM text', () => {
const result = validatePEMCertificate('This is not a PEM certificate');
expect(result.valid).toBe(false);
});
it('should accept PEM with extra whitespace', () => {
const pemWithWhitespace = '\n\n' + validPEM + '\n\n';
expect(validatePEMCertificate(pemWithWhitespace).valid).toBe(true);
});
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* Runtime validators and type guards
*
* Provides validation functions for user input, configuration values,
* and runtime type checking for SealedSecret objects.
*/
import { SealedSecretInterface, SealedSecretScope } from '../types';
import { SealedSecret } from './SealedSecretCRD';
/**
* Runtime type guard for SealedSecret
*
* @param obj Object to check
* @returns true if obj is a SealedSecret instance
*/
export function isSealedSecret(obj: any): obj is SealedSecret {
return (
obj instanceof SealedSecret &&
obj.jsonData &&
'spec' in obj.jsonData &&
'encryptedData' in obj.jsonData.spec
);
}
/**
* Validate SealedSecret structure
*
* @param obj Object to validate
* @returns true if obj has valid SealedSecret structure
*/
export function validateSealedSecretInterface(obj: any): obj is SealedSecretInterface {
return (
typeof obj === 'object' &&
obj !== null &&
'spec' in obj &&
typeof obj.spec === 'object' &&
'encryptedData' in obj.spec &&
typeof obj.spec.encryptedData === 'object'
);
}
/**
* Validate scope value
*
* @param value Value to check
* @returns true if value is a valid SealedSecretScope
*/
export function isSealedSecretScope(value: any): value is SealedSecretScope {
return ['strict', 'namespace-wide', 'cluster-wide'].includes(value);
}
/**
* Validate Kubernetes resource name
*
* Must match DNS-1123 subdomain:
* - lowercase alphanumeric characters, '-' or '.'
* - start and end with alphanumeric character
* - max 253 characters
*
* @param name Name to validate
* @returns true if valid Kubernetes resource name
*/
export function isValidK8sName(name: string): boolean {
if (!name || name.length === 0 || name.length > 253) {
return false;
}
// DNS-1123 subdomain format
return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/.test(name);
}
/**
* Validate Kubernetes label/annotation key
*
* @param key Key to validate
* @returns true if valid Kubernetes key
*/
export function isValidK8sKey(key: string): boolean {
if (!key || key.length === 0 || key.length > 253) {
return false;
}
// Simple alphanumeric key validation
return /^[a-zA-Z0-9]([-_.a-zA-Z0-9]*[a-zA-Z0-9])?$/.test(key);
}
/**
* Validate PEM certificate format
*
* Checks for BEGIN/END CERTIFICATE markers and basic structure
*
* @param value String to validate
* @returns true if valid PEM format
*/
export function isValidPEM(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
// Check for PEM markers and basic structure
const pemRegex = /^-----BEGIN CERTIFICATE-----\s+[\s\S]+\s+-----END CERTIFICATE-----\s*$/;
return pemRegex.test(value.trim());
}
/**
* Validate that a value is not empty
*
* @param value Value to check
* @returns true if value is non-empty string
*/
export function isNonEmpty(value: string): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
/**
* Validate namespace name
*
* Same rules as resource names
*
* @param namespace Namespace to validate
* @returns true if valid namespace name
*/
export function isValidNamespace(namespace: string): boolean {
return isValidK8sName(namespace);
}
/**
* Validation result with error message
*/
export interface ValidationResult {
valid: boolean;
error?: string;
}
/**
* Validate secret name with detailed error message
*
* @param name Secret name to validate
* @returns Validation result with error message if invalid
*/
export function validateSecretName(name: string): ValidationResult {
if (!name || name.trim().length === 0) {
return { valid: false, error: 'Secret name is required' };
}
if (name.length > 253) {
return { valid: false, error: 'Secret name must be 253 characters or less' };
}
if (!isValidK8sName(name)) {
return {
valid: false,
error:
'Secret name must be lowercase alphanumeric, may contain hyphens and dots, and must start/end with alphanumeric',
};
}
return { valid: true };
}
/**
* Validate secret key name with detailed error message
*
* @param key Key name to validate
* @returns Validation result with error message if invalid
*/
export function validateSecretKey(key: string): ValidationResult {
if (!key || key.trim().length === 0) {
return { valid: false, error: 'Key name is required' };
}
if (key.length > 253) {
return { valid: false, error: 'Key name must be 253 characters or less' };
}
if (!isValidK8sKey(key)) {
return {
valid: false,
error: 'Key name must be alphanumeric and may contain hyphens, underscores, and dots',
};
}
return { valid: true };
}
/**
* Validate secret value (plaintext)
*
* @param value Secret value to validate
* @returns Validation result with error message if invalid
*/
export function validateSecretValue(value: string): ValidationResult {
if (!value || value.trim().length === 0) {
return { valid: false, error: 'Secret value is required' };
}
// Check for reasonable size limit (1MB)
if (value.length > 1024 * 1024) {
return { valid: false, error: 'Secret value must be less than 1MB' };
}
return { valid: true };
}
/**
* Validate PEM certificate with detailed error message
*
* @param pem PEM certificate to validate
* @returns Validation result with error message if invalid
*/
export function validatePEMCertificate(pem: string): ValidationResult {
if (!pem || pem.trim().length === 0) {
return { valid: false, error: 'Certificate is required' };
}
if (!isValidPEM(pem)) {
return {
valid: false,
error: 'Invalid PEM format. Must contain BEGIN CERTIFICATE and END CERTIFICATE markers',
};
}
return { valid: true };
}
/**
* Validate plugin configuration
*
* @param config Configuration to validate
* @returns Validation result with error message if invalid
*/
export function validatePluginConfig(config: {
controllerName?: string;
controllerNamespace?: string;
controllerPort?: number;
}): ValidationResult {
if (!config.controllerName || !isValidK8sName(config.controllerName)) {
return { valid: false, error: 'Invalid controller name' };
}
if (!config.controllerNamespace || !isValidNamespace(config.controllerNamespace)) {
return { valid: false, error: 'Invalid controller namespace' };
}
if (!config.controllerPort || config.controllerPort < 1 || config.controllerPort > 65535) {
return { valid: false, error: 'Invalid controller port (must be 1-65535)' };
}
return { valid: true };
}