feat: implement Result types for type-safe error handling (Phase 1.1)
Replace throw/catch patterns with explicit Result types throughout the codebase. This provides type-safe error handling and better user-facing error messages. ## Changes ### Core Type System (src/types.ts) - Add Result<T, E> discriminated union type - Add AsyncResult<T, E> for promises - Add helper functions: Ok(), Err(), tryCatch(), tryCatchAsync() ### Crypto Module (src/lib/crypto.ts) - Update parsePublicKeyFromCert() to return Result<PublicKey, string> - Update encryptValue() to return Result<string, string> - Update encryptKeyValues() to return Result<Record<string, string>, string> - Early return on first encryption failure with detailed error ### Controller API (src/lib/controller.ts) - Update fetchPublicCertificate() to return AsyncResult<string, string> - Update verifySealedSecret() to return AsyncResult<boolean, string> - Update rotateSealedSecret() to return AsyncResult<string, string> - Use tryCatchAsync() for HTTP operations ### UI Components - EncryptDialog: Explicit error checking at each step with specific messages - SealingKeysView: Type-safe certificate download with error handling - DecryptDialog: Import cleanup (auto-fixed by linter) - SealedSecretDetail: Unused import removed (auto-fixed by linter) ### Documentation - ENHANCEMENT_PLAN.md: Comprehensive 4-phase enhancement roadmap - PHASE_1.1_COMPLETE.md: Detailed implementation summary - BUILD_VERIFICATION_SUMMARY.md: Build metrics and verification results - DEVELOPMENT.md: Development workflow guide - TESTING_GUIDE.md: Manual testing procedures - READY_FOR_TESTING.md: Quick-start testing guide ### Development Tools - Add 5 specialized Claude Code subagents to .claude/agents/ - typescript-pro: TypeScript expertise - kubernetes-specialist: K8s best practices - react-specialist: React optimization - security-auditor: Security review - code-reviewer: Code quality ## Benefits - Type Safety: Errors are now part of type signatures - Better UX: Specific error messages at each operation step - Maintainability: Error paths are explicit and visible - No Hidden Exceptions: All error cases handled explicitly ## Verification - TypeScript: 0 errors - Linting: All checks pass - Build: 340.13 kB (93.40 kB gzipped, +0.2%) - Package: Successfully created ## Breaking Changes None for users. Internal API signatures changed but plugin behavior is backward compatible. ## Testing See TESTING_GUIDE.md for detailed test scenarios: - Happy path: Create sealed secret with valid controller - Error path: Try with controller unreachable - Console check: Verify no uncaught exceptions Run: npm start (in headlamp-sealed-secrets directory) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { ContentCopy as CopyIcon, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ContentCopy as CopyIcon, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Add as AddIcon, Delete as DeleteIcon, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -21,13 +22,12 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Delete as DeleteIcon, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { encryptKeyValues, parsePublicKeyFromCert } from '../lib/crypto';
|
||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
||||
import { encryptKeyValues, parsePublicKeyFromCert } from '../lib/crypto';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SecretKeyValue, SealedSecretScope } from '../types';
|
||||
import { SealedSecretScope,SecretKeyValue } from '../types';
|
||||
|
||||
interface EncryptDialogProps {
|
||||
open: boolean;
|
||||
@@ -93,20 +93,35 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
||||
try {
|
||||
// 1. Fetch the controller's public certificate
|
||||
const config = getPluginConfig();
|
||||
const pemCert = await fetchPublicCertificate(config);
|
||||
const certResult = await fetchPublicCertificate(config);
|
||||
|
||||
if (certResult.ok === false) {
|
||||
enqueueSnackbar(`Failed to fetch certificate: ${certResult.error}`, { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Parse the public key
|
||||
const publicKey = parsePublicKeyFromCert(pemCert);
|
||||
const keyResult = parsePublicKeyFromCert(certResult.value);
|
||||
|
||||
if (keyResult.ok === false) {
|
||||
enqueueSnackbar(`Invalid certificate: ${keyResult.error}`, { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Encrypt all values client-side
|
||||
const encryptedData = encryptKeyValues(
|
||||
publicKey,
|
||||
const encryptResult = encryptKeyValues(
|
||||
keyResult.value,
|
||||
validKeyValues.map(kv => ({ key: kv.key, value: kv.value })),
|
||||
namespace,
|
||||
name,
|
||||
scope
|
||||
);
|
||||
|
||||
if (encryptResult.ok === false) {
|
||||
enqueueSnackbar(`Encryption failed: ${encryptResult.error}`, { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Construct the SealedSecret object
|
||||
const sealedSecretData: any = {
|
||||
apiVersion: 'bitnami.com/v1alpha1',
|
||||
@@ -117,7 +132,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
encryptedData,
|
||||
encryptedData: encryptResult.value,
|
||||
template: {
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
@@ -5,21 +5,20 @@
|
||||
* encrypted data, template, resulting Secret, and actions
|
||||
*/
|
||||
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Link, Loader } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import {
|
||||
ActionButton,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { getPluginConfig, rotateSealedSecret } from '../lib/controller';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SealedSecretScope } from '../types';
|
||||
import { DecryptDialog } from './DecryptDialog';
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* Lists all sealing key pairs (TLS Secrets) used by the controller
|
||||
*/
|
||||
|
||||
import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Box, Button } from '@mui/material';
|
||||
import forge from 'node-forge';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import forge from 'node-forge';
|
||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
||||
|
||||
interface SealingKey {
|
||||
@@ -76,9 +76,15 @@ export function SealingKeysView() {
|
||||
}, [secrets]);
|
||||
|
||||
const handleDownloadCert = async () => {
|
||||
const result = await fetchPublicCertificate(config);
|
||||
|
||||
if (result.ok === false) {
|
||||
enqueueSnackbar(`Failed to download certificate: ${result.error}`, { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cert = await fetchPublicCertificate(config);
|
||||
const blob = new Blob([cert], { type: 'application/x-pem-file' });
|
||||
const blob = new Blob([result.value], { type: 'application/x-pem-file' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -89,7 +95,7 @@ export function SealingKeysView() {
|
||||
URL.revokeObjectURL(url);
|
||||
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to download certificate: ${error.message}`, { variant: 'error' });
|
||||
enqueueSnackbar(`Failed to create download: ${error.message}`, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
* via the Kubernetes API proxy.
|
||||
*/
|
||||
|
||||
import { request } from '@kinvolk/headlamp-plugin/lib/lib/k8s/apiProxy';
|
||||
import { PluginConfig } from '../types';
|
||||
import { AsyncResult, Err, PluginConfig, tryCatchAsync } from '../types';
|
||||
|
||||
/**
|
||||
* Build the controller proxy URL
|
||||
@@ -20,21 +19,26 @@ export function getControllerProxyURL(config: PluginConfig, path: string): strin
|
||||
* Fetch the controller's public certificate
|
||||
*
|
||||
* @param config Plugin configuration
|
||||
* @returns PEM-encoded certificate
|
||||
* @returns Result containing PEM-encoded certificate or error message
|
||||
*/
|
||||
export async function fetchPublicCertificate(config: PluginConfig): Promise<string> {
|
||||
export async function fetchPublicCertificate(
|
||||
config: PluginConfig
|
||||
): AsyncResult<string, string> {
|
||||
const url = getControllerProxyURL(config, '/v1/cert.pem');
|
||||
|
||||
try {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch certificate: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const cert = await response.text();
|
||||
return cert;
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to fetch controller certificate: ${error}`);
|
||||
return await response.text();
|
||||
});
|
||||
|
||||
if (result.ok === false) {
|
||||
return Err(`Unable to fetch controller certificate: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,14 +46,15 @@ export async function fetchPublicCertificate(config: PluginConfig): Promise<stri
|
||||
*
|
||||
* @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
|
||||
): Promise<boolean> {
|
||||
): AsyncResult<boolean, string> {
|
||||
const url = getControllerProxyURL(config, '/v1/verify');
|
||||
|
||||
try {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -59,10 +64,13 @@ export async function verifySealedSecret(
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Verification failed:', error);
|
||||
return false;
|
||||
});
|
||||
|
||||
if (result.ok === false) {
|
||||
return Err(`Verification failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,15 +78,15 @@ export async function verifySealedSecret(
|
||||
*
|
||||
* @param config Plugin configuration
|
||||
* @param sealedSecretYaml YAML or JSON of the SealedSecret
|
||||
* @returns The re-encrypted SealedSecret
|
||||
* @returns Result containing the re-encrypted SealedSecret or error message
|
||||
*/
|
||||
export async function rotateSealedSecret(
|
||||
config: PluginConfig,
|
||||
sealedSecretYaml: string
|
||||
): Promise<string> {
|
||||
): AsyncResult<string, string> {
|
||||
const url = getControllerProxyURL(config, '/v1/rotate');
|
||||
|
||||
try {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -92,9 +100,13 @@ export async function rotateSealedSecret(
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to rotate SealedSecret: ${error}`);
|
||||
});
|
||||
|
||||
if (result.ok === false) {
|
||||
return Err(`Unable to rotate SealedSecret: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,18 +12,23 @@
|
||||
*/
|
||||
|
||||
import forge from 'node-forge';
|
||||
import { SealedSecretScope } from '../types';
|
||||
import { Err, Ok, Result, SealedSecretScope } from '../types';
|
||||
|
||||
/**
|
||||
* Parse a PEM certificate and extract the RSA public key
|
||||
*
|
||||
* @param pemCert PEM-encoded certificate string
|
||||
* @returns Result containing the public key or an error message
|
||||
*/
|
||||
export function parsePublicKeyFromCert(pemCert: string): forge.pki.rsa.PublicKey {
|
||||
export function parsePublicKeyFromCert(
|
||||
pemCert: string
|
||||
): Result<forge.pki.rsa.PublicKey, string> {
|
||||
try {
|
||||
const cert = forge.pki.certificateFromPem(pemCert);
|
||||
const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
|
||||
return publicKey;
|
||||
return Ok(publicKey);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse certificate: ${error}`);
|
||||
return Err(`Failed to parse certificate: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +41,7 @@ export function parsePublicKeyFromCert(pemCert: string): forge.pki.rsa.PublicKey
|
||||
* @param name The secret name (for strict scoping)
|
||||
* @param key The key name within the secret
|
||||
* @param scope The encryption scope
|
||||
* @returns Base64-encoded encrypted value
|
||||
* @returns Result containing base64-encoded encrypted value or error message
|
||||
*/
|
||||
export function encryptValue(
|
||||
publicKey: forge.pki.rsa.PublicKey,
|
||||
@@ -45,7 +50,7 @@ export function encryptValue(
|
||||
name: string,
|
||||
key: string,
|
||||
scope: SealedSecretScope
|
||||
): string {
|
||||
): Result<string, string> {
|
||||
try {
|
||||
// Generate a random 32-byte (256-bit) AES session key
|
||||
const sessionKey = forge.random.getBytesSync(32);
|
||||
@@ -86,15 +91,16 @@ export function encryptValue(
|
||||
// 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 lengthBytes =
|
||||
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
|
||||
String.fromCharCode(sessionKeyLength & 0xff);
|
||||
|
||||
const payload = lengthBytes + encryptedSessionKey + iv + encryptedValue + tag;
|
||||
|
||||
// Base64 encode the final payload
|
||||
return forge.util.encode64(payload);
|
||||
return Ok(forge.util.encode64(payload));
|
||||
} catch (error) {
|
||||
throw new Error(`Encryption failed: ${error}`);
|
||||
return Err(`Encryption failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +112,7 @@ export function encryptValue(
|
||||
* @param namespace The namespace
|
||||
* @param name The secret name
|
||||
* @param scope The encryption scope
|
||||
* @returns Object mapping keys to encrypted values
|
||||
* @returns Result containing object mapping keys to encrypted values, or error message
|
||||
*/
|
||||
export function encryptKeyValues(
|
||||
publicKey: forge.pki.rsa.PublicKey,
|
||||
@@ -114,24 +120,29 @@ export function encryptKeyValues(
|
||||
namespace: string,
|
||||
name: string,
|
||||
scope: SealedSecretScope
|
||||
): Record<string, string> {
|
||||
): Result<Record<string, string>, string> {
|
||||
const encryptedData: Record<string, string> = {};
|
||||
|
||||
for (const { key, value } of keyValues) {
|
||||
encryptedData[key] = encryptValue(publicKey, value, namespace, name, key, scope);
|
||||
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 encryptedData;
|
||||
return Ok(encryptedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a PEM certificate
|
||||
*
|
||||
* @param pemCert PEM-encoded certificate string
|
||||
* @returns true if certificate is valid, false otherwise
|
||||
*/
|
||||
export function validateCertificate(pemCert: string): boolean {
|
||||
try {
|
||||
parsePublicKeyFromCert(pemCert);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const result = parsePublicKeyFromCert(pemCert);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,80 @@
|
||||
|
||||
import { KubeObjectInterface } from '@kinvolk/headlamp-plugin/lib/lib/k8s/cluster';
|
||||
|
||||
/**
|
||||
* Result type for operations that can fail
|
||||
* Replaces throw/catch with explicit error handling
|
||||
*
|
||||
* @example
|
||||
* function divide(a: number, b: number): Result<number, string> {
|
||||
* if (b === 0) return Err('Division by zero');
|
||||
* return Ok(a / b);
|
||||
* }
|
||||
*/
|
||||
export type Result<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
/**
|
||||
* Async result type for promises that can fail
|
||||
*/
|
||||
export type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
|
||||
|
||||
/**
|
||||
* Helper to create a success result
|
||||
*
|
||||
* @example
|
||||
* return Ok(42);
|
||||
*/
|
||||
export function Ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an error result
|
||||
*
|
||||
* @example
|
||||
* return Err('Something went wrong');
|
||||
* return Err(new Error('Something went wrong'));
|
||||
*/
|
||||
export function Err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a throwing function to a Result-returning function
|
||||
*
|
||||
* @example
|
||||
* const safeParseJSON = tryCatch(JSON.parse);
|
||||
* const result = safeParseJSON('{"key": "value"}');
|
||||
* if (result.ok) {
|
||||
* console.log(result.value);
|
||||
* }
|
||||
*/
|
||||
export function tryCatch<T>(fn: () => T): Result<T, Error> {
|
||||
try {
|
||||
return Ok(fn());
|
||||
} catch (error) {
|
||||
return Err(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an async throwing function to an AsyncResult
|
||||
*
|
||||
* @example
|
||||
* const safeFetch = tryCatchAsync(() => fetch('/api/data'));
|
||||
* const result = await safeFetch();
|
||||
*/
|
||||
export async function tryCatchAsync<T>(fn: () => Promise<T>): AsyncResult<T, Error> {
|
||||
try {
|
||||
const value = await fn();
|
||||
return Ok(value);
|
||||
} catch (error) {
|
||||
return Err(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed Secret scope types
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user