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:
2026-02-11 21:09:10 -05:00
parent b0d86831b7
commit 286e88fece
18 changed files with 5619 additions and 56 deletions
@@ -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' });
}
};