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:
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Controller Status Indicator
|
||||
*
|
||||
* Displays the health status of the sealed-secrets controller,
|
||||
* including reachability, response time, and version information.
|
||||
*/
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Box, Chip, Tooltip, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useControllerHealth } from '../hooks/useControllerHealth';
|
||||
import { ControllerHealthSkeleton } from './LoadingSkeletons';
|
||||
|
||||
interface ControllerStatusProps {
|
||||
/** Whether to auto-refresh the status */
|
||||
autoRefresh?: boolean;
|
||||
/** Refresh interval in milliseconds (default: 30000 = 30s) */
|
||||
refreshIntervalMs?: number;
|
||||
/** Show detailed info (latency, version) */
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller status indicator component
|
||||
*/
|
||||
export function ControllerStatus({
|
||||
autoRefresh = false,
|
||||
refreshIntervalMs = 30000,
|
||||
showDetails = true,
|
||||
}: ControllerStatusProps) {
|
||||
const { health: status, loading } = useControllerHealth(autoRefresh, refreshIntervalMs);
|
||||
|
||||
// Show skeleton while loading
|
||||
if (loading || !status) {
|
||||
return <ControllerHealthSkeleton />;
|
||||
}
|
||||
|
||||
// Build status message and icon
|
||||
let statusColor: 'success' | 'error' | 'warning' = 'error';
|
||||
let statusIcon = 'mdi:alert-circle';
|
||||
let statusLabel = 'Unreachable';
|
||||
let tooltipText = status.error || 'Controller is unreachable';
|
||||
|
||||
if (status.healthy) {
|
||||
statusColor = 'success';
|
||||
statusIcon = 'mdi:check-circle';
|
||||
statusLabel = 'Healthy';
|
||||
tooltipText = `Controller is healthy${status.version ? ` (${status.version})` : ''}`;
|
||||
} else if (status.reachable) {
|
||||
statusColor = 'warning';
|
||||
statusIcon = 'mdi:alert';
|
||||
statusLabel = 'Unhealthy';
|
||||
tooltipText = status.error || 'Controller responded but is not healthy';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={tooltipText}>
|
||||
<Chip
|
||||
icon={<Icon icon={statusIcon} />}
|
||||
label={statusLabel}
|
||||
color={statusColor}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{showDetails && status.healthy && (
|
||||
<>
|
||||
{status.latencyMs !== undefined && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status.latencyMs}ms
|
||||
</Typography>
|
||||
)}
|
||||
{status.version && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
v{status.version}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Decrypt Dialog
|
||||
*
|
||||
* Shows the decrypted value of a secret key by reading the resulting
|
||||
* Kubernetes Secret (requires RBAC permissions to read secrets)
|
||||
*/
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
|
||||
interface DecryptDialogProps {
|
||||
sealedSecret: SealedSecret;
|
||||
secretKey: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt dialog component
|
||||
*/
|
||||
export function DecryptDialog({ sealedSecret, secretKey, onClose }: DecryptDialogProps) {
|
||||
const [secret] = K8s.ResourceClasses.Secret.useGet(
|
||||
sealedSecret.metadata.name,
|
||||
sealedSecret.metadata.namespace
|
||||
);
|
||||
const [showValue, setShowValue] = React.useState(false);
|
||||
const [countdown, setCountdown] = React.useState(30);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
// Auto-hide after 30 seconds
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
onClose();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (secret && secret.data?.[secretKey]) {
|
||||
const decoded = atob(secret.data[secretKey]);
|
||||
navigator.clipboard.writeText(decoded);
|
||||
enqueueSnackbar('Copied to clipboard', { variant: 'success' });
|
||||
}
|
||||
};
|
||||
|
||||
// Check if secret exists
|
||||
if (!secret) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
aria-labelledby="decrypt-error-title"
|
||||
aria-describedby="decrypt-error-description"
|
||||
>
|
||||
<DialogTitle id="decrypt-error-title">Secret Not Found</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography id="decrypt-error-description">
|
||||
The Kubernetes Secret for this SealedSecret has not been created yet, or you don't have
|
||||
permission to read it.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} aria-label="Close dialog">Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if key exists
|
||||
const encodedValue = secret.data?.[secretKey];
|
||||
if (!encodedValue) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
aria-labelledby="decrypt-key-error-title"
|
||||
aria-describedby="decrypt-key-error-description"
|
||||
>
|
||||
<DialogTitle id="decrypt-key-error-title">Key Not Found</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography id="decrypt-key-error-description">
|
||||
The key <strong>{secretKey}</strong> was not found in the Secret.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} aria-label="Close dialog">Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const decodedValue = atob(encodedValue);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
aria-labelledby="decrypt-dialog-title"
|
||||
aria-describedby="decrypt-dialog-description"
|
||||
>
|
||||
<DialogTitle id="decrypt-dialog-title">
|
||||
Decrypted Value: {secretKey}
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
color="text.secondary"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
Auto-closing in {countdown} seconds
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }} id="decrypt-dialog-description">
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
value={decodedValue}
|
||||
type={showValue ? 'text' : 'password'}
|
||||
inputProps={{
|
||||
'aria-label': `Decrypted value for ${secretKey}`,
|
||||
readOnly: true,
|
||||
}}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<IconButton
|
||||
onClick={() => setShowValue(!showValue)}
|
||||
size="small"
|
||||
aria-label={showValue ? 'Hide secret value' : 'Show secret value'}
|
||||
title={showValue ? 'Hide secret value' : 'Show secret value'}
|
||||
>
|
||||
{showValue ? <Icon icon="mdi:eye-off" /> : <Icon icon="mdi:eye" />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleCopy}
|
||||
size="small"
|
||||
aria-label="Copy value to clipboard"
|
||||
title="Copy value to clipboard"
|
||||
>
|
||||
<Icon icon="mdi:content-copy" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{ mt: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>Security Warning:</strong> This value is sensitive. Ensure no one is looking
|
||||
over your shoulder.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} aria-label="Close dialog">Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Encryption Dialog
|
||||
*
|
||||
* Dialog for creating new SealedSecrets by encrypting secret values
|
||||
* client-side using the controller's public certificate
|
||||
*/
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { useSealedSecretEncryption } from '../hooks/useSealedSecretEncryption';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SealedSecretScope, SecretKeyValue } from '../types';
|
||||
|
||||
interface EncryptDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt dialog component
|
||||
*/
|
||||
export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
||||
const [name, setName] = React.useState('');
|
||||
const [namespace, setNamespace] = React.useState('default');
|
||||
const [scope, setScope] = React.useState<SealedSecretScope>('strict');
|
||||
const [keyValues, setKeyValues] = React.useState<(SecretKeyValue & { showValue: boolean })[]>([
|
||||
{ key: '', value: '', showValue: false },
|
||||
]);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const { encrypt, encrypting } = useSealedSecretEncryption();
|
||||
|
||||
const [namespaces] = K8s.ResourceClasses.Namespace.useList();
|
||||
|
||||
// Memoize callbacks to prevent re-renders
|
||||
const handleAddKeyValue = React.useCallback(() => {
|
||||
setKeyValues(prev => [...prev, { key: '', value: '', showValue: false }]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveKeyValue = React.useCallback((index: number) => {
|
||||
setKeyValues(prev => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleKeyChange = React.useCallback((index: number, key: string) => {
|
||||
setKeyValues(prev => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], key };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleValueChange = React.useCallback((index: number, value: string) => {
|
||||
setKeyValues(prev => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], value };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShowValue = React.useCallback((index: number) => {
|
||||
setKeyValues(prev => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], showValue: !updated[index].showValue };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
// Filter out empty rows
|
||||
const validKeyValues = keyValues.filter(kv => kv.key || kv.value).map(kv => ({
|
||||
key: kv.key,
|
||||
value: kv.value,
|
||||
}));
|
||||
|
||||
// Use the encryption hook
|
||||
const result = await encrypt({
|
||||
name,
|
||||
namespace,
|
||||
scope,
|
||||
keyValues: validKeyValues,
|
||||
});
|
||||
|
||||
// If encryption failed, the hook already showed the error
|
||||
if (result.ok === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Apply the SealedSecret to the cluster
|
||||
await SealedSecret.apiEndpoint.post(result.value.sealedSecretData);
|
||||
|
||||
enqueueSnackbar('SealedSecret created successfully', { variant: 'success' });
|
||||
|
||||
// Clear form and close
|
||||
setName('');
|
||||
setNamespace('default');
|
||||
setScope('strict');
|
||||
setKeyValues([{ key: '', value: '', showValue: false }]);
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to create SealedSecret: ${error.message}`, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
aria-labelledby="encrypt-dialog-title"
|
||||
aria-describedby="encrypt-dialog-description"
|
||||
>
|
||||
<DialogTitle id="encrypt-dialog-title">Create Sealed Secret</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2 }} id="encrypt-dialog-description">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Secret Name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
margin="normal"
|
||||
required
|
||||
inputProps={{
|
||||
'aria-label': 'Secret name',
|
||||
'aria-required': true,
|
||||
}}
|
||||
helperText="Must be a valid Kubernetes resource name (lowercase alphanumeric, hyphens)"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth margin="normal" required>
|
||||
<InputLabel id="encrypt-namespace-label">Namespace</InputLabel>
|
||||
<Select
|
||||
value={namespace}
|
||||
label="Namespace"
|
||||
onChange={e => setNamespace(e.target.value)}
|
||||
labelId="encrypt-namespace-label"
|
||||
inputProps={{
|
||||
'aria-label': 'Namespace for the SealedSecret',
|
||||
'aria-required': true,
|
||||
}}
|
||||
>
|
||||
{namespaces?.map(ns => (
|
||||
<MenuItem key={ns.metadata.name} value={ns.metadata.name}>
|
||||
{ns.metadata.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="normal" required>
|
||||
<InputLabel id="encrypt-scope-label">Scope</InputLabel>
|
||||
<Select
|
||||
value={scope}
|
||||
label="Scope"
|
||||
onChange={e => setScope(e.target.value as SealedSecretScope)}
|
||||
labelId="encrypt-scope-label"
|
||||
inputProps={{
|
||||
'aria-label': 'Encryption scope for the SealedSecret',
|
||||
'aria-required': true,
|
||||
}}
|
||||
>
|
||||
<MenuItem value="strict">Strict (name + namespace bound)</MenuItem>
|
||||
<MenuItem value="namespace-wide">Namespace-wide (namespace bound)</MenuItem>
|
||||
<MenuItem value="cluster-wide">Cluster-wide (no binding)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
|
||||
Secret Data
|
||||
</Typography>
|
||||
|
||||
{keyValues.map((kv, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}
|
||||
role="group"
|
||||
aria-label={`Secret key-value pair ${index + 1}`}
|
||||
>
|
||||
<TextField
|
||||
label="Key Name"
|
||||
value={kv.key}
|
||||
onChange={e => handleKeyChange(index, e.target.value)}
|
||||
sx={{ flex: 1 }}
|
||||
inputProps={{
|
||||
'aria-label': `Key name ${index + 1}`,
|
||||
}}
|
||||
helperText={index === 0 ? 'Alphanumeric, hyphens, underscores, or dots' : undefined}
|
||||
/>
|
||||
<TextField
|
||||
label="Secret Value"
|
||||
type={kv.showValue ? 'text' : 'password'}
|
||||
value={kv.value}
|
||||
onChange={e => handleValueChange(index, e.target.value)}
|
||||
sx={{ flex: 2 }}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputProps={{
|
||||
'aria-label': `Secret value for ${kv.key || `key ${index + 1}`}`,
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => toggleShowValue(index)}
|
||||
edge="end"
|
||||
aria-label={kv.showValue ? 'Hide password' : 'Show password'}
|
||||
tabIndex={0}
|
||||
>
|
||||
{kv.showValue ? <Icon icon="mdi:eye-off" /> : <Icon icon="mdi:eye" />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => handleRemoveKeyValue(index)}
|
||||
disabled={keyValues.length === 1}
|
||||
color="error"
|
||||
aria-label={`Remove key-value pair ${index + 1}`}
|
||||
title={keyValues.length === 1 ? 'At least one key-value pair is required' : `Remove key-value pair ${index + 1}`}
|
||||
>
|
||||
<Icon icon="mdi:delete" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Button
|
||||
startIcon={<Icon icon="mdi:plus" />}
|
||||
onClick={handleAddKeyValue}
|
||||
aria-label="Add another key-value pair"
|
||||
>
|
||||
Add Another Key
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{ mt: 2, p: 2, bgcolor: 'info.light', borderRadius: 1 }}
|
||||
role="note"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>Security Note:</strong> Secret values are encrypted entirely in your browser
|
||||
using the controller's public key. The plaintext values never leave your machine.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={encrypting} aria-label="Cancel creation">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
variant="contained"
|
||||
disabled={encrypting}
|
||||
aria-busy={encrypting}
|
||||
aria-label={encrypting ? 'Encrypting and creating SealedSecret' : 'Create SealedSecret'}
|
||||
>
|
||||
{encrypting ? 'Encrypting & Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Error Boundary Components
|
||||
*
|
||||
* Provides graceful error handling for crypto and API operations
|
||||
*/
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Alert, Box, Button, Typography } from '@mui/material';
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: React.ErrorInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base error boundary component
|
||||
*/
|
||||
abstract class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
console.error('Error type:', typeof error);
|
||||
console.error('Error keys:', Object.keys(error));
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error toString:', String(error));
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
if (this.props.onReset) {
|
||||
this.props.onReset();
|
||||
}
|
||||
};
|
||||
|
||||
abstract renderError(): ReactNode;
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.renderError();
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for cryptographic operations
|
||||
*
|
||||
* Catches errors during encryption/decryption and provides
|
||||
* helpful context about what might have gone wrong.
|
||||
*/
|
||||
export class CryptoErrorBoundary extends BaseErrorBoundary {
|
||||
renderError() {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Alert
|
||||
severity="error"
|
||||
icon={<Icon icon="mdi:alert-circle-outline" />}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={this.handleReset}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Cryptographic Operation Failed
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
An error occurred during encryption or decryption. This might indicate:
|
||||
</Typography>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>Invalid or expired controller certificate</li>
|
||||
<li>Browser cryptography compatibility issue</li>
|
||||
<li>Malformed secret data</li>
|
||||
<li>Controller not reachable or misconfigured</li>
|
||||
</ul>
|
||||
{this.state.error && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const msg = this.state.error.message || this.state.error.toString();
|
||||
return `Error: ${String(msg)}`;
|
||||
} catch (e) {
|
||||
return 'Error: [Unable to display error message]';
|
||||
}
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for API operations
|
||||
*
|
||||
* Catches errors during Kubernetes API calls and provides
|
||||
* guidance for troubleshooting connectivity issues.
|
||||
*/
|
||||
export class ApiErrorBoundary extends BaseErrorBoundary {
|
||||
renderError() {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Alert
|
||||
severity="error"
|
||||
icon={<Icon icon="mdi:alert-circle-outline" />}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={this.handleReset}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
API Communication Error
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
Failed to communicate with the Kubernetes API or Sealed Secrets controller.
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
Please verify:
|
||||
</Typography>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>Kubernetes cluster is accessible</li>
|
||||
<li>Sealed Secrets controller is running</li>
|
||||
<li>Controller configuration is correct (name, namespace, port)</li>
|
||||
<li>Network connectivity to the cluster</li>
|
||||
</ul>
|
||||
{this.state.error && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const msg = this.state.error.message || this.state.error.toString();
|
||||
return `Error: ${String(msg)}`;
|
||||
} catch (e) {
|
||||
return 'Error: [Unable to display error message]';
|
||||
}
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error boundary for general component errors
|
||||
*
|
||||
* Provides a fallback UI when components encounter unexpected errors.
|
||||
*/
|
||||
export class GenericErrorBoundary extends BaseErrorBoundary {
|
||||
renderError() {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Alert
|
||||
severity="error"
|
||||
icon={<Icon icon="mdi:alert-circle-outline" />}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={this.handleReset}>
|
||||
Reload
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Something Went Wrong
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
An unexpected error occurred. Please try reloading the page or contact your administrator
|
||||
if the problem persists.
|
||||
</Typography>
|
||||
{this.state.error && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const msg = this.state.error.message || this.state.error.toString();
|
||||
return `Error: ${String(msg)}`;
|
||||
} catch (e) {
|
||||
return 'Error: [Unable to display error message]';
|
||||
}
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Loading Skeleton Components
|
||||
*
|
||||
* Provides visual feedback during data loading with skeleton screens
|
||||
* to improve perceived performance and user experience.
|
||||
*/
|
||||
|
||||
import { Box, Skeleton } from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Skeleton for SealedSecrets list view
|
||||
*
|
||||
* Shows placeholder rows while data is loading
|
||||
*/
|
||||
export function SealedSecretListSkeleton() {
|
||||
return (
|
||||
<Box p={2}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="rectangular"
|
||||
height={60}
|
||||
sx={{ mb: 1, borderRadius: 1 }}
|
||||
animation="wave"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for SealedSecret detail view
|
||||
*
|
||||
* Shows placeholder sections while resource is loading
|
||||
*/
|
||||
export function SealedSecretDetailSkeleton() {
|
||||
return (
|
||||
<Box p={3}>
|
||||
{/* Title */}
|
||||
<Skeleton variant="text" width="40%" height={40} sx={{ mb: 3 }} animation="wave" />
|
||||
|
||||
{/* Metadata section */}
|
||||
<Skeleton variant="rectangular" height={200} sx={{ mb: 2, borderRadius: 1 }} animation="wave" />
|
||||
|
||||
{/* Encrypted data section */}
|
||||
<Skeleton variant="rectangular" height={150} sx={{ mb: 2, borderRadius: 1 }} animation="wave" />
|
||||
|
||||
{/* Actions section */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Skeleton variant="rectangular" width={120} height={36} sx={{ borderRadius: 1 }} animation="wave" />
|
||||
<Skeleton variant="rectangular" width={120} height={36} sx={{ borderRadius: 1 }} animation="wave" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for sealing keys list view
|
||||
*
|
||||
* Shows placeholder for certificate information
|
||||
*/
|
||||
export function SealingKeysListSkeleton() {
|
||||
return (
|
||||
<Box p={2}>
|
||||
{[1, 2].map(i => (
|
||||
<Box key={i} sx={{ mb: 3 }}>
|
||||
<Skeleton variant="text" width="30%" height={32} sx={{ mb: 1 }} animation="wave" />
|
||||
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 1, mb: 1 }} animation="wave" />
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Skeleton variant="rectangular" width={100} height={28} sx={{ borderRadius: 1 }} animation="wave" />
|
||||
<Skeleton variant="rectangular" width={100} height={28} sx={{ borderRadius: 1 }} animation="wave" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for certificate information
|
||||
*
|
||||
* Shows placeholder for certificate metadata
|
||||
*/
|
||||
export function CertificateInfoSkeleton() {
|
||||
return (
|
||||
<Box>
|
||||
<Skeleton variant="text" width="60%" animation="wave" />
|
||||
<Skeleton variant="text" width="40%" animation="wave" />
|
||||
<Skeleton variant="text" width="50%" animation="wave" />
|
||||
<Skeleton variant="text" width="45%" animation="wave" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for controller health status
|
||||
*
|
||||
* Shows placeholder for health check information
|
||||
*/
|
||||
export function ControllerHealthSkeleton() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Skeleton variant="circular" width={40} height={40} animation="wave" />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="40%" animation="wave" />
|
||||
<Skeleton variant="text" width="60%" animation="wave" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* SealedSecret Detail View
|
||||
*
|
||||
* Shows detailed information about a specific SealedSecret including
|
||||
* encrypted data, template, resulting Secret, and actions
|
||||
*/
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { getPluginConfig, rotateSealedSecret } from '../lib/controller';
|
||||
import { canDecryptSecrets } from '../lib/rbac';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SealedSecretScope } from '../types';
|
||||
import { DecryptDialog } from './DecryptDialog';
|
||||
import { SealedSecretDetailSkeleton } from './LoadingSkeletons';
|
||||
|
||||
/**
|
||||
* Format scope for display
|
||||
*/
|
||||
function formatScope(scope: SealedSecretScope): string {
|
||||
switch (scope) {
|
||||
case 'strict':
|
||||
return 'Strict';
|
||||
case 'namespace-wide':
|
||||
return 'Namespace-wide';
|
||||
case 'cluster-wide':
|
||||
return 'Cluster-wide';
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SealedSecret detail view component
|
||||
*/
|
||||
export function SealedSecretDetail() {
|
||||
const { namespace = '', name = '' } = useParams<{ namespace: string; name: string }>();
|
||||
const [sealedSecret, error] = SealedSecret.useGet(name || undefined, namespace || undefined);
|
||||
const [secret] = K8s.ResourceClasses.Secret.useGet(name || undefined, namespace || undefined);
|
||||
const [decryptKey, setDecryptKey] = React.useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
const [rotating, setRotating] = React.useState(false);
|
||||
const [canDecrypt, setCanDecrypt] = React.useState(false);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const { permissions } = usePermissions(namespace || undefined);
|
||||
|
||||
// Check if user can decrypt secrets (requires get permission on Secrets)
|
||||
React.useEffect(() => {
|
||||
if (namespace) {
|
||||
canDecryptSecrets(namespace).then(setCanDecrypt);
|
||||
}
|
||||
}, [namespace]);
|
||||
|
||||
// Wait for required params before rendering
|
||||
if (!namespace || !name) {
|
||||
return <SealedSecretDetailSkeleton />;
|
||||
}
|
||||
|
||||
// Show error if fetch failed
|
||||
if (error) {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Alert severity="error">
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Failed to load SealedSecret
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{String(error)}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading skeleton while data is being fetched
|
||||
if (!sealedSecret) {
|
||||
return <SealedSecretDetailSkeleton />;
|
||||
}
|
||||
|
||||
// Memoize callbacks to prevent re-renders
|
||||
const handleDelete = React.useCallback(async () => {
|
||||
try {
|
||||
await sealedSecret.delete();
|
||||
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
|
||||
window.history.back();
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to delete SealedSecret: ${error.message}`, { variant: 'error' });
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}, [sealedSecret, enqueueSnackbar]);
|
||||
|
||||
const handleRotate = React.useCallback(async () => {
|
||||
setRotating(true);
|
||||
try {
|
||||
const config = getPluginConfig();
|
||||
const yaml = JSON.stringify(sealedSecret.jsonData);
|
||||
await rotateSealedSecret(config, yaml);
|
||||
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
|
||||
// The resource will auto-refresh via the watch
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to re-encrypt: ${error.message}`, { variant: 'error' });
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
}, [sealedSecret, enqueueSnackbar]);
|
||||
|
||||
// Safety check - should never happen due to early returns above, but be defensive
|
||||
if (!sealedSecret?.spec?.encryptedData) {
|
||||
return <SealedSecretDetailSkeleton />;
|
||||
}
|
||||
|
||||
const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData);
|
||||
|
||||
const handleClose = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open
|
||||
onClose={handleClose}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: { xs: '100%', sm: '600px', md: '800px' },
|
||||
maxWidth: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ height: '100%', overflow: 'auto' }}>
|
||||
<SectionBox
|
||||
title={
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<IconButton onClick={handleClose} edge="start" size="small">
|
||||
<Icon icon="mdi:close" />
|
||||
</IconButton>
|
||||
<span>{sealedSecret.metadata.name}</span>
|
||||
</Box>
|
||||
<Box>
|
||||
{permissions?.canUpdate && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleRotate}
|
||||
disabled={rotating}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{rotating ? 'Re-encrypting...' : 'Re-encrypt'}
|
||||
</Button>
|
||||
)}
|
||||
{permissions?.canDelete && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Name',
|
||||
value: String(sealedSecret.metadata.name || ''),
|
||||
},
|
||||
{
|
||||
name: 'Namespace',
|
||||
value: String(sealedSecret.metadata.namespace || ''),
|
||||
},
|
||||
{
|
||||
name: 'Scope',
|
||||
value: String(formatScope(sealedSecret.scope)),
|
||||
},
|
||||
{
|
||||
name: 'Sync Status',
|
||||
value: (
|
||||
<StatusLabel status={sealedSecret.isSynced ? 'success' : 'error'}>
|
||||
{sealedSecret.isSynced ? 'Synced' : 'Not Synced'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Status Message',
|
||||
value: String(sealedSecret.syncMessage || 'Unknown'),
|
||||
hide: !sealedSecret.syncCondition,
|
||||
},
|
||||
{
|
||||
name: 'Age',
|
||||
value: String(sealedSecret.getAge() || ''),
|
||||
},
|
||||
{
|
||||
name: 'Created',
|
||||
value: sealedSecret.metadata.creationTimestamp
|
||||
? new Date(sealedSecret.metadata.creationTimestamp).toLocaleString()
|
||||
: 'Unknown',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Encrypted Data">
|
||||
<SimpleTable
|
||||
data={encryptedKeys.map(key => ({
|
||||
key,
|
||||
value: sealedSecret.spec.encryptedData[key],
|
||||
}))}
|
||||
columns={[
|
||||
{
|
||||
label: 'Key',
|
||||
getter: (row: any) => row.key,
|
||||
},
|
||||
{
|
||||
label: 'Encrypted Value',
|
||||
getter: (row: any) => {
|
||||
const val = row.value;
|
||||
return val.length > 40 ? val.substring(0, 40) + '...' : val;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
getter: (row: any) =>
|
||||
canDecrypt ? (
|
||||
<Button size="small" onClick={() => setDecryptKey(row.key)}>
|
||||
Decrypt
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" disabled title="No permission to access Secrets">
|
||||
Decrypt
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{sealedSecret.spec.template && (
|
||||
<SectionBox title="Template">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Secret Type',
|
||||
value: String(sealedSecret.spec.template.type || 'Opaque'),
|
||||
},
|
||||
{
|
||||
name: 'Labels',
|
||||
value: String(JSON.stringify(sealedSecret.spec.template.metadata?.labels || {})),
|
||||
hide: !sealedSecret.spec.template.metadata?.labels,
|
||||
},
|
||||
{
|
||||
name: 'Annotations',
|
||||
value: String(
|
||||
JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {})
|
||||
),
|
||||
hide: !sealedSecret.spec.template.metadata?.annotations,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
<SectionBox title="Resulting Secret">
|
||||
{secret ? (
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="success">Secret exists</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Keys',
|
||||
value: String(Object.keys(secret.data || {}).join(', ') || 'None'),
|
||||
},
|
||||
{
|
||||
name: 'Link',
|
||||
value: (
|
||||
<Link
|
||||
routeName="secret"
|
||||
params={{
|
||||
namespace: String(secret.metadata.namespace || ''),
|
||||
name: String(secret.metadata.name || ''),
|
||||
}}
|
||||
>
|
||||
View Secret
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Box p={2}>
|
||||
<StatusLabel status="warning">Secret not yet created</StatusLabel>
|
||||
<p>The controller will create the Secret once it processes this SealedSecret.</p>
|
||||
</Box>
|
||||
)}
|
||||
</SectionBox>
|
||||
</Box>
|
||||
|
||||
{decryptKey && (
|
||||
<DecryptDialog
|
||||
sealedSecret={sealedSecret}
|
||||
secretKey={decryptKey}
|
||||
onClose={() => setDecryptKey(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete SealedSecret?</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
|
||||
delete the resulting Kubernetes Secret.
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* SealedSecrets List View
|
||||
*
|
||||
* Displays all SealedSecrets in the cluster with filtering and navigation
|
||||
*/
|
||||
|
||||
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import {
|
||||
SectionBox,
|
||||
SectionFilterHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Box, Button } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { usePermission } from '../hooks/usePermissions';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SealedSecretScope } from '../types';
|
||||
import { EncryptDialog } from './EncryptDialog';
|
||||
import { SealedSecretListSkeleton } from './LoadingSkeletons';
|
||||
import { SealedSecretDetail } from './SealedSecretDetail';
|
||||
import { VersionWarning } from './VersionWarning';
|
||||
|
||||
/**
|
||||
* Format scope for display
|
||||
*/
|
||||
function formatScope(scope: SealedSecretScope): string {
|
||||
switch (scope) {
|
||||
case 'strict':
|
||||
return 'Strict';
|
||||
case 'namespace-wide':
|
||||
return 'Namespace-wide';
|
||||
case 'cluster-wide':
|
||||
return 'Cluster-wide';
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SealedSecrets list view component
|
||||
*/
|
||||
export function SealedSecretList() {
|
||||
const { namespace, name } = useParams<{ namespace?: string; name?: string }>();
|
||||
const [sealedSecrets, error, loading] = SealedSecret.useList();
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
const { allowed: canCreate } = usePermission(undefined, 'canCreate');
|
||||
|
||||
// Memoize callbacks to prevent re-renders
|
||||
const handleOpenDialog = React.useCallback(() => {
|
||||
setCreateDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDialog = React.useCallback(() => {
|
||||
setCreateDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
// Memoize column definitions (stable reference for table)
|
||||
const columns = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'Name',
|
||||
getter: (ss: SealedSecret) => (
|
||||
<Link
|
||||
routeName="sealedsecret"
|
||||
params={{
|
||||
namespace: ss.metadata.namespace,
|
||||
name: ss.metadata.name,
|
||||
}}
|
||||
>
|
||||
{ss.metadata.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Namespace',
|
||||
getter: (ss: SealedSecret) => ss.metadata.namespace,
|
||||
},
|
||||
{
|
||||
label: 'Encrypted Keys',
|
||||
getter: (ss: SealedSecret) => ss.encryptedKeysCount,
|
||||
},
|
||||
{
|
||||
label: 'Scope',
|
||||
getter: (ss: SealedSecret) => formatScope(ss.scope),
|
||||
},
|
||||
{
|
||||
label: 'Sync Status',
|
||||
getter: (ss: SealedSecret) => (
|
||||
<StatusLabel status={ss.isSynced ? 'success' : 'error'}>
|
||||
{ss.isSynced ? 'Synced' : 'Not Synced'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (ss: SealedSecret) => ss.getAge(),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize actions array (stable reference)
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
canCreate
|
||||
? [
|
||||
<Button key="create" variant="contained" color="primary" onClick={handleOpenDialog}>
|
||||
Create Sealed Secret
|
||||
</Button>,
|
||||
]
|
||||
: [],
|
||||
[canCreate, handleOpenDialog]
|
||||
);
|
||||
|
||||
// Show loading skeleton while data is being fetched
|
||||
if (loading) {
|
||||
return (
|
||||
<SectionBox title="Sealed Secrets">
|
||||
<SealedSecretListSkeleton />
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error if CRD is not installed
|
||||
if (error) {
|
||||
return (
|
||||
<SectionBox
|
||||
title="Sealed Secrets"
|
||||
>
|
||||
<Box p={2}>
|
||||
<StatusLabel status="error">Error</StatusLabel>
|
||||
<Box mt={2}>
|
||||
{error.message.includes('404') ? (
|
||||
<>
|
||||
<p>
|
||||
Sealed Secrets CRD not found. Please ensure Sealed Secrets is installed on your
|
||||
cluster.
|
||||
</p>
|
||||
<p>
|
||||
Install with: <code>kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml</code>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Failed to load Sealed Secrets: {error.message}</p>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox
|
||||
title="Sealed Secrets"
|
||||
>
|
||||
<VersionWarning autoDetect showDetails={false} />
|
||||
<SectionFilterHeader title="" noNamespaceFilter={false} actions={actions} />
|
||||
<SimpleTable data={sealedSecrets} columns={columns} />
|
||||
</SectionBox>
|
||||
|
||||
<EncryptDialog open={createDialogOpen} onClose={handleCloseDialog} />
|
||||
|
||||
{namespace && name && <SealedSecretDetail />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Sealing Keys Management View
|
||||
*
|
||||
* Lists all sealing key pairs (TLS Secrets) used by the controller
|
||||
*/
|
||||
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Box, Button, Chip } from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
||||
import { isCertificateExpiringSoon, parseCertificateInfo } from '../lib/crypto';
|
||||
import { CertificateInfo, PEMCertificate } from '../types';
|
||||
import { ControllerStatus } from './ControllerStatus';
|
||||
import { SealingKeysListSkeleton } from './LoadingSkeletons';
|
||||
|
||||
interface SealingKey {
|
||||
name: string;
|
||||
status: 'active' | 'compromised';
|
||||
created: string;
|
||||
certInfo?: CertificateInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealing keys management view
|
||||
*/
|
||||
export function SealingKeysView() {
|
||||
const config = getPluginConfig();
|
||||
const [secrets, , loading] = K8s.ResourceClasses.Secret.useList({ namespace: config.controllerNamespace });
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
// Filter for sealing key secrets
|
||||
const sealingKeys: SealingKey[] = React.useMemo(() => {
|
||||
if (!secrets) return [];
|
||||
|
||||
return secrets
|
||||
.filter(secret => {
|
||||
const labelValue = secret.metadata.labels?.['sealedsecrets.bitnami.com/sealed-secrets-key'];
|
||||
return labelValue === 'active' || labelValue === 'compromised';
|
||||
})
|
||||
.map(secret => {
|
||||
const status = secret.metadata.labels?.['sealedsecrets.bitnami.com/sealed-secrets-key'] as
|
||||
| 'active'
|
||||
| 'compromised';
|
||||
const certPem = secret.data?.['tls.crt'] ? atob(secret.data['tls.crt']) : '';
|
||||
|
||||
let certInfo: CertificateInfo | undefined;
|
||||
if (certPem) {
|
||||
const infoResult = parseCertificateInfo(PEMCertificate(certPem));
|
||||
if (infoResult.ok) {
|
||||
certInfo = infoResult.value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: secret.metadata.name!,
|
||||
status,
|
||||
created: secret.metadata.creationTimestamp!,
|
||||
certInfo,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort active keys first, then by creation date (newest first)
|
||||
if (a.status !== b.status) {
|
||||
return a.status === 'active' ? -1 : 1;
|
||||
}
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
});
|
||||
}, [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 blob = new Blob([result.value], { type: 'application/x-pem-file' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sealed-secrets-cert.pem';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to create download: ${error.message}`, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading skeleton while data is being fetched
|
||||
if (loading) {
|
||||
return (
|
||||
<SectionBox title="Sealing Keys">
|
||||
<SealingKeysListSkeleton />
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox
|
||||
title="Sealing Keys"
|
||||
headerProps={{
|
||||
actions: [
|
||||
<ControllerStatus key="status" autoRefresh refreshIntervalMs={60000} showDetails />,
|
||||
<Button key="download" variant="contained" onClick={handleDownloadCert}>
|
||||
Download Public Certificate
|
||||
</Button>,
|
||||
],
|
||||
}}
|
||||
>
|
||||
{sealingKeys.length === 0 ? (
|
||||
<Box p={2}>
|
||||
<p>No sealing keys found in namespace {config.controllerNamespace}.</p>
|
||||
<p>
|
||||
Ensure the Sealed Secrets controller is installed and running in the{' '}
|
||||
<strong>{config.controllerNamespace}</strong> namespace.
|
||||
</p>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box p={2}>
|
||||
<p>
|
||||
Sealing keys are TLS key pairs used by the controller to decrypt SealedSecrets. The
|
||||
active key is used for new encryptions. Old keys are kept to decrypt existing
|
||||
SealedSecrets.
|
||||
</p>
|
||||
</Box>
|
||||
<SimpleTable
|
||||
data={sealingKeys}
|
||||
columns={[
|
||||
{
|
||||
label: 'Key Name',
|
||||
getter: (key: SealingKey) => key.name,
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (key: SealingKey) => (
|
||||
<StatusLabel status={key.status === 'active' ? 'success' : 'warning'}>
|
||||
{key.status === 'active' ? 'Active' : 'Compromised'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Created',
|
||||
getter: (key: SealingKey) => new Date(key.created).toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: 'Certificate Expiry',
|
||||
getter: (key: SealingKey) => {
|
||||
if (!key.certInfo) return 'N/A';
|
||||
|
||||
const { certInfo } = key;
|
||||
const expiryDate = certInfo.validTo.toLocaleDateString();
|
||||
|
||||
if (certInfo.isExpired) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label="Expired" color="error" size="small" />
|
||||
<span>{expiryDate}</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCertificateExpiringSoon(certInfo, 30)) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${certInfo.daysUntilExpiry} days left`}
|
||||
color="warning"
|
||||
size="small"
|
||||
/>
|
||||
<span>{expiryDate}</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<span>{expiryDate}</span>
|
||||
<span style={{ color: '#666', fontSize: '0.9em' }}>
|
||||
({certInfo.daysUntilExpiry} days)
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Secret Details Section
|
||||
*
|
||||
* Additional section shown in the Secret detail view if the Secret
|
||||
* is owned by a SealedSecret
|
||||
*/
|
||||
|
||||
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { NameValueTable, SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
|
||||
interface SecretDetailsSectionProps {
|
||||
resource: any; // The Secret resource
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret details section component
|
||||
*/
|
||||
export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) {
|
||||
// Check if this Secret is owned by a SealedSecret
|
||||
const ownerRef = resource.metadata?.ownerReferences?.find(
|
||||
(ref: any) => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1'
|
||||
);
|
||||
|
||||
if (!ownerRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch the parent SealedSecret
|
||||
const [sealedSecret] = SealedSecret.useGet(ownerRef.name, resource.metadata.namespace);
|
||||
|
||||
return (
|
||||
<SectionBox title="Sealed Secret">
|
||||
{sealedSecret ? (
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Parent SealedSecret',
|
||||
value: (
|
||||
<Link
|
||||
routeName="sealedsecret"
|
||||
params={{
|
||||
namespace: sealedSecret.metadata.namespace,
|
||||
name: sealedSecret.metadata.name,
|
||||
}}
|
||||
>
|
||||
{sealedSecret.metadata.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Scope',
|
||||
value: sealedSecret.scope,
|
||||
},
|
||||
{
|
||||
name: 'Sync Status',
|
||||
value: (
|
||||
<StatusLabel status={sealedSecret.isSynced ? 'success' : 'error'}>
|
||||
{sealedSecret.isSynced ? 'Synced' : 'Not Synced'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p>Loading SealedSecret information...</p>
|
||||
)}
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Settings Page
|
||||
*
|
||||
* Configuration page for the Sealed Secrets plugin
|
||||
*/
|
||||
|
||||
import { SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Box, Button, Divider, TextField, Typography } from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { getPluginConfig, savePluginConfig } from '../lib/controller';
|
||||
import { PluginConfig } from '../types';
|
||||
import { ControllerStatus } from './ControllerStatus';
|
||||
import { VersionWarning } from './VersionWarning';
|
||||
|
||||
interface PluginSettingsProps {
|
||||
data?: { [key: string]: string | number | boolean };
|
||||
onDataChange?: (data: { [key: string]: string | number | boolean }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page component
|
||||
*/
|
||||
export function SettingsPage(props: PluginSettingsProps) {
|
||||
const { data, onDataChange } = props;
|
||||
const storedConfig = getPluginConfig();
|
||||
const [config, setConfig] = React.useState<PluginConfig>({
|
||||
controllerName: (data?.controllerName as string) ?? storedConfig.controllerName,
|
||||
controllerNamespace: (data?.controllerNamespace as string) ?? storedConfig.controllerNamespace,
|
||||
controllerPort: (data?.controllerPort as number) ?? storedConfig.controllerPort,
|
||||
});
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const handleSave = () => {
|
||||
savePluginConfig(config);
|
||||
onDataChange?.(config as unknown as { [key: string]: string | number | boolean });
|
||||
enqueueSnackbar('Settings saved successfully', { variant: 'success' });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultConfig: PluginConfig = {
|
||||
controllerName: 'sealed-secrets-controller',
|
||||
controllerNamespace: 'kube-system',
|
||||
controllerPort: 8080,
|
||||
};
|
||||
setConfig(defaultConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBox title="Sealed Secrets Plugin Settings">
|
||||
<Box p={3}>
|
||||
<Typography variant="body1" paragraph id="settings-description">
|
||||
Configure the connection to your Sealed Secrets controller. These settings are stored in
|
||||
your browser's local storage.
|
||||
</Typography>
|
||||
|
||||
{/* API Version Detection */}
|
||||
<VersionWarning autoDetect showDetails />
|
||||
|
||||
{/* Controller Health Status */}
|
||||
<Box
|
||||
mb={3}
|
||||
p={2}
|
||||
bgcolor="background.paper"
|
||||
borderRadius={1}
|
||||
border={1}
|
||||
borderColor="divider"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom id="controller-status-label">
|
||||
Controller Status
|
||||
</Typography>
|
||||
<ControllerStatus autoRefresh showDetails />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} role="separator" />
|
||||
|
||||
<form aria-labelledby="settings-form-title">
|
||||
<Typography variant="h6" id="settings-form-title" sx={{ mb: 2 }} className="sr-only">
|
||||
Controller Configuration
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Controller Name"
|
||||
value={config.controllerName}
|
||||
onChange={e => {
|
||||
const newConfig = { ...config, controllerName: e.target.value };
|
||||
setConfig(newConfig);
|
||||
onDataChange?.(newConfig as unknown as { [key: string]: string | number | boolean });
|
||||
}}
|
||||
margin="normal"
|
||||
helperText="Name of the sealed-secrets-controller deployment/service"
|
||||
inputProps={{
|
||||
'aria-label': 'Controller name',
|
||||
'aria-describedby': 'controller-name-help',
|
||||
}}
|
||||
FormHelperTextProps={{
|
||||
id: 'controller-name-help',
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Controller Namespace"
|
||||
value={config.controllerNamespace}
|
||||
onChange={e => {
|
||||
const newConfig = { ...config, controllerNamespace: e.target.value };
|
||||
setConfig(newConfig);
|
||||
onDataChange?.(newConfig as unknown as { [key: string]: string | number | boolean });
|
||||
}}
|
||||
margin="normal"
|
||||
helperText="Namespace where the controller is installed"
|
||||
inputProps={{
|
||||
'aria-label': 'Controller namespace',
|
||||
'aria-describedby': 'controller-namespace-help',
|
||||
}}
|
||||
FormHelperTextProps={{
|
||||
id: 'controller-namespace-help',
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Controller Port"
|
||||
type="number"
|
||||
value={config.controllerPort}
|
||||
onChange={e => {
|
||||
const newConfig = { ...config, controllerPort: parseInt(e.target.value, 10) };
|
||||
setConfig(newConfig);
|
||||
onDataChange?.(newConfig as unknown as { [key: string]: string | number | boolean });
|
||||
}}
|
||||
margin="normal"
|
||||
helperText="HTTP port of the controller service"
|
||||
inputProps={{
|
||||
'aria-label': 'Controller port',
|
||||
'aria-describedby': 'controller-port-help',
|
||||
min: 1,
|
||||
max: 65535,
|
||||
}}
|
||||
FormHelperTextProps={{
|
||||
id: 'controller-port-help',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box mt={3} display="flex" gap={2} role="group" aria-label="Settings actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
aria-label="Save configuration settings"
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleReset}
|
||||
aria-label="Reset settings to default values"
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
<Box mt={4} p={2} bgcolor="info.light" borderRadius={1} role="note">
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Default Values
|
||||
</Typography>
|
||||
<Typography variant="body2" component="dl">
|
||||
<dt style={{ display: 'inline', fontWeight: 'bold' }}>Controller Name:</dt>{' '}
|
||||
<dd style={{ display: 'inline', margin: 0 }}>sealed-secrets-controller</dd>
|
||||
<br />
|
||||
<dt style={{ display: 'inline', fontWeight: 'bold' }}>Controller Namespace:</dt>{' '}
|
||||
<dd style={{ display: 'inline', margin: 0 }}>kube-system</dd>
|
||||
<br />
|
||||
<dt style={{ display: 'inline', fontWeight: 'bold' }}>Controller Port:</dt>{' '}
|
||||
<dd style={{ display: 'inline', margin: 0 }}>8080</dd>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Version Warning Component
|
||||
*
|
||||
* Displays warnings about API version compatibility and issues.
|
||||
*/
|
||||
|
||||
import { Alert, Box, Button, Link } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
|
||||
export interface VersionWarningProps {
|
||||
/** Whether to auto-detect version on mount */
|
||||
autoDetect?: boolean;
|
||||
/** Whether to show detailed version information */
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that detects and displays API version information
|
||||
*
|
||||
* Shows warnings if:
|
||||
* - CRD is not installed
|
||||
* - Version detection fails
|
||||
* - Using non-default version (informational)
|
||||
*/
|
||||
export function VersionWarning({ autoDetect = true, showDetails = false }: VersionWarningProps) {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [detectedVersion, setDetectedVersion] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const detectVersion = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await SealedSecret.detectApiVersion();
|
||||
|
||||
if (result.ok) {
|
||||
setDetectedVersion(result.value);
|
||||
setError(null);
|
||||
} else if (result.ok === false) {
|
||||
setDetectedVersion(null);
|
||||
// Ensure error is always a string
|
||||
const errorMessage = typeof result.error === 'string'
|
||||
? result.error
|
||||
: String(result.error);
|
||||
setError(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
// Catch any unexpected errors
|
||||
setDetectedVersion(null);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoDetect) {
|
||||
detectVersion();
|
||||
}
|
||||
}, [autoDetect, detectVersion]);
|
||||
|
||||
// Don't show anything while loading
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show error if detection failed
|
||||
if (error) {
|
||||
return (
|
||||
<Box mb={2}>
|
||||
<Alert severity="error" action={
|
||||
<Button color="inherit" size="small" onClick={detectVersion}>
|
||||
Retry
|
||||
</Button>
|
||||
}>
|
||||
<strong>API Version Detection Failed</strong>
|
||||
<br />
|
||||
{String(error)}
|
||||
{String(error).includes('not found') && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
Install Sealed Secrets with:{' '}
|
||||
<code style={{ fontSize: '0.875em' }}>
|
||||
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml
|
||||
</code>
|
||||
<br />
|
||||
Or visit:{' '}
|
||||
<Link
|
||||
href="https://github.com/bitnami-labs/sealed-secrets"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="inherit"
|
||||
>
|
||||
github.com/bitnami-labs/sealed-secrets
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show informational message if using non-default version
|
||||
if (detectedVersion && detectedVersion !== SealedSecret.DEFAULT_VERSION) {
|
||||
return (
|
||||
<Box mb={2}>
|
||||
<Alert severity="info">
|
||||
<strong>API Version Detected</strong>
|
||||
<br />
|
||||
Using API version: <code>{detectedVersion}</code>
|
||||
{showDetails && (
|
||||
<>
|
||||
<br />
|
||||
Default version: <code>{SealedSecret.DEFAULT_VERSION}</code>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success if explicitly showing details
|
||||
if (showDetails && detectedVersion) {
|
||||
return (
|
||||
<Box mb={2}>
|
||||
<Alert severity="success">
|
||||
<strong>API Version Detected</strong>
|
||||
<br />
|
||||
Using API version: <code>{detectedVersion}</code>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: show nothing (version detected successfully)
|
||||
return null;
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 The Kubernetes Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/// <reference types="@kinvolk/headlamp-plugin" />
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Custom Hook for Controller Health Monitoring
|
||||
*
|
||||
* Provides controller health status with automatic refresh capability.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { checkControllerHealth, ControllerHealthStatus, getPluginConfig } from '../lib/controller';
|
||||
|
||||
/**
|
||||
* Custom hook for monitoring controller health
|
||||
*
|
||||
* Automatically checks controller health on mount and can optionally
|
||||
* refresh at a specified interval.
|
||||
*
|
||||
* @param autoRefresh Whether to automatically refresh health status
|
||||
* @param refreshIntervalMs Refresh interval in milliseconds (default: 30000ms = 30s)
|
||||
* @returns Object with health status, loading state, and manual refresh function
|
||||
*
|
||||
* @example
|
||||
* // Manual refresh only
|
||||
* const { health, loading, refresh } = useControllerHealth();
|
||||
*
|
||||
* // Auto-refresh every 30 seconds
|
||||
* const { health, loading } = useControllerHealth(true, 30000);
|
||||
*
|
||||
* // Auto-refresh every 10 seconds
|
||||
* const { health, loading } = useControllerHealth(true, 10000);
|
||||
*/
|
||||
export function useControllerHealth(autoRefresh = false, refreshIntervalMs = 30000) {
|
||||
const [health, setHealth] = React.useState<ControllerHealthStatus | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
const fetchHealth = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
const config = getPluginConfig();
|
||||
const result = await checkControllerHealth(config);
|
||||
|
||||
if (result.ok) {
|
||||
setHealth(result.value);
|
||||
} else if (result.ok === false) {
|
||||
// Even on error, checkControllerHealth returns a status
|
||||
// This shouldn't happen, but handle gracefully
|
||||
setHealth({
|
||||
healthy: false,
|
||||
reachable: false,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Initial fetch and auto-refresh setup
|
||||
React.useEffect(() => {
|
||||
fetchHealth();
|
||||
|
||||
if (autoRefresh) {
|
||||
const interval = setInterval(fetchHealth, refreshIntervalMs);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [autoRefresh, refreshIntervalMs, fetchHealth]);
|
||||
|
||||
return {
|
||||
health,
|
||||
loading,
|
||||
refresh: fetchHealth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* React Hooks for RBAC Permission Checking
|
||||
*
|
||||
* Provides React hooks for checking and caching user permissions
|
||||
* for SealedSecrets and related resources.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { checkSealedSecretPermissions, ResourcePermissions } from '../lib/rbac';
|
||||
|
||||
/**
|
||||
* Hook to check SealedSecret permissions for a namespace
|
||||
*
|
||||
* Automatically fetches permissions on mount and when namespace changes.
|
||||
* Returns loading state and permissions.
|
||||
*
|
||||
* @param namespace Optional namespace to check (cluster-wide if omitted)
|
||||
* @returns Object with loading state, permissions, and error
|
||||
*
|
||||
* @example
|
||||
* const { loading, permissions, error } = usePermissions('default');
|
||||
* if (!loading && permissions?.canCreate) {
|
||||
* // Show create button
|
||||
* }
|
||||
*/
|
||||
export function usePermissions(namespace?: string) {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [permissions, setPermissions] = React.useState<ResourcePermissions | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function fetchPermissions() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await checkSealedSecretPermissions(namespace);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.ok) {
|
||||
setPermissions(result.value);
|
||||
setError(null);
|
||||
} else if (result.ok === false) {
|
||||
setPermissions(null);
|
||||
setError(result.error);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fetchPermissions();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [namespace]);
|
||||
|
||||
return { loading, permissions, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check a specific permission
|
||||
*
|
||||
* Useful when you only need to check one permission (e.g., canCreate)
|
||||
* instead of fetching all permissions.
|
||||
*
|
||||
* @param namespace Optional namespace to check
|
||||
* @param permission Permission key to check
|
||||
* @returns Object with loading state and allowed flag
|
||||
*
|
||||
* @example
|
||||
* const { loading, allowed } = usePermission('default', 'canCreate');
|
||||
* if (allowed) {
|
||||
* // Show create button
|
||||
* }
|
||||
*/
|
||||
export function usePermission(
|
||||
namespace: string | undefined,
|
||||
permission: keyof ResourcePermissions
|
||||
) {
|
||||
const { loading, permissions } = usePermissions(namespace);
|
||||
const allowed = permissions?.[permission] ?? false;
|
||||
|
||||
return { loading, allowed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if user has any write permissions
|
||||
*
|
||||
* Returns true if user can create, update, or delete.
|
||||
* Useful for showing/hiding entire sections of UI.
|
||||
*
|
||||
* @param namespace Optional namespace to check
|
||||
* @returns Object with loading state and hasWriteAccess flag
|
||||
*
|
||||
* @example
|
||||
* const { loading, hasWriteAccess } = useHasWriteAccess('default');
|
||||
* if (hasWriteAccess) {
|
||||
* // Show management UI
|
||||
* }
|
||||
*/
|
||||
export function useHasWriteAccess(namespace?: string) {
|
||||
const { loading, permissions } = usePermissions(namespace);
|
||||
|
||||
const hasWriteAccess =
|
||||
permissions?.canCreate || permissions?.canUpdate || permissions?.canDelete || false;
|
||||
|
||||
return { loading, hasWriteAccess };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if user has read-only access
|
||||
*
|
||||
* Returns true if user can read/list but cannot create/update/delete.
|
||||
*
|
||||
* @param namespace Optional namespace to check
|
||||
* @returns Object with loading state and isReadOnly flag
|
||||
*
|
||||
* @example
|
||||
* const { loading, isReadOnly } = useIsReadOnly('default');
|
||||
* if (isReadOnly) {
|
||||
* // Show read-only warning
|
||||
* }
|
||||
*/
|
||||
export function useIsReadOnly(namespace?: string) {
|
||||
const { loading, permissions } = usePermissions(namespace);
|
||||
|
||||
const isReadOnly =
|
||||
(permissions?.canRead || permissions?.canList) &&
|
||||
!permissions?.canCreate &&
|
||||
!permissions?.canUpdate &&
|
||||
!permissions?.canDelete;
|
||||
|
||||
return { loading, isReadOnly };
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Custom Hook for SealedSecret Encryption
|
||||
*
|
||||
* Encapsulates the business logic for encrypting secrets and creating SealedSecrets.
|
||||
* Handles certificate fetching, validation, expiry warnings, encryption, and object creation.
|
||||
*/
|
||||
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
||||
import {
|
||||
encryptKeyValues,
|
||||
isCertificateExpiringSoon,
|
||||
parseCertificateInfo,
|
||||
parsePublicKeyFromCert,
|
||||
} from '../lib/crypto';
|
||||
import { validateSecretKey, validateSecretName, validateSecretValue } from '../lib/validators';
|
||||
import {
|
||||
AsyncResult,
|
||||
CertificateInfo,
|
||||
Err,
|
||||
Ok,
|
||||
PlaintextValue,
|
||||
SealedSecretScope,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Request parameters for encryption
|
||||
*/
|
||||
export interface EncryptionRequest {
|
||||
/** Name of the SealedSecret to create */
|
||||
name: string;
|
||||
/** Namespace to create the SealedSecret in */
|
||||
namespace: string;
|
||||
/** Encryption scope (strict, namespace-wide, cluster-wide) */
|
||||
scope: SealedSecretScope;
|
||||
/** Key-value pairs to encrypt */
|
||||
keyValues: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of successful encryption
|
||||
*/
|
||||
export interface EncryptionResult {
|
||||
/** The complete SealedSecret object ready to apply */
|
||||
sealedSecretData: any;
|
||||
/** Information about the certificate used */
|
||||
certificateInfo?: CertificateInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for SealedSecret encryption
|
||||
*
|
||||
* Provides encryption functionality with built-in validation, error handling,
|
||||
* and user notifications.
|
||||
*
|
||||
* @returns Object with encrypt function and encrypting state
|
||||
*
|
||||
* @example
|
||||
* const { encrypt, encrypting } = useSealedSecretEncryption();
|
||||
*
|
||||
* const result = await encrypt({
|
||||
* name: 'my-secret',
|
||||
* namespace: 'default',
|
||||
* scope: 'strict',
|
||||
* keyValues: [{ key: 'password', value: 'secret123' }]
|
||||
* });
|
||||
*
|
||||
* if (result.ok) {
|
||||
* // Use result.value.sealedSecretData
|
||||
* }
|
||||
*/
|
||||
export function useSealedSecretEncryption() {
|
||||
const [encrypting, setEncrypting] = React.useState(false);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const encrypt = React.useCallback(
|
||||
async (request: EncryptionRequest): AsyncResult<EncryptionResult, string> => {
|
||||
setEncrypting(true);
|
||||
|
||||
try {
|
||||
// Step 1: Validate inputs
|
||||
const nameValidation = validateSecretName(request.name);
|
||||
if (!nameValidation.valid) {
|
||||
enqueueSnackbar(nameValidation.error, { variant: 'error' });
|
||||
return Err(nameValidation.error || 'Invalid secret name');
|
||||
}
|
||||
|
||||
// Validate all key-value pairs
|
||||
for (const kv of request.keyValues) {
|
||||
const keyValidation = validateSecretKey(kv.key);
|
||||
if (!keyValidation.valid) {
|
||||
const error = `Invalid key "${kv.key}": ${keyValidation.error}`;
|
||||
enqueueSnackbar(error, { variant: 'error' });
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
const valueValidation = validateSecretValue(kv.value);
|
||||
if (!valueValidation.valid) {
|
||||
const error = `Invalid value for key "${kv.key}": ${valueValidation.error}`;
|
||||
enqueueSnackbar(error, { variant: 'error' });
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.keyValues.length === 0) {
|
||||
const error = 'At least one key-value pair is required';
|
||||
enqueueSnackbar(error, { variant: 'error' });
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Step 2: Fetch the controller's public certificate
|
||||
const config = getPluginConfig();
|
||||
const certResult = await fetchPublicCertificate(config);
|
||||
|
||||
if (certResult.ok === false) {
|
||||
const error = `Failed to fetch certificate: ${certResult.error}`;
|
||||
enqueueSnackbar(error, { variant: 'error' });
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Step 3: Check certificate expiry and warn user
|
||||
let certInfo: CertificateInfo | undefined;
|
||||
const certInfoResult = parseCertificateInfo(certResult.value);
|
||||
if (certInfoResult.ok) {
|
||||
certInfo = certInfoResult.value;
|
||||
|
||||
if (certInfo.isExpired) {
|
||||
enqueueSnackbar(
|
||||
`Warning: Controller certificate expired on ${certInfo.validTo.toLocaleDateString()}. ` +
|
||||
'Secrets may not be decryptable.',
|
||||
{ variant: 'warning' }
|
||||
);
|
||||
} else if (isCertificateExpiringSoon(certInfo, 30)) {
|
||||
enqueueSnackbar(
|
||||
`Warning: Controller certificate expires in ${certInfo.daysUntilExpiry} days ` +
|
||||
`(${certInfo.validTo.toLocaleDateString()}).`,
|
||||
{ variant: 'warning' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Parse the public key from certificate
|
||||
const keyResult = parsePublicKeyFromCert(certResult.value);
|
||||
|
||||
if (keyResult.ok === false) {
|
||||
const error = `Invalid certificate: ${keyResult.error}`;
|
||||
enqueueSnackbar(error, { variant: 'error' });
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Step 5: Encrypt all values client-side
|
||||
const encryptResult = encryptKeyValues(
|
||||
keyResult.value,
|
||||
request.keyValues.map(kv => ({ key: kv.key, value: PlaintextValue(kv.value) })),
|
||||
request.namespace,
|
||||
request.name,
|
||||
request.scope
|
||||
);
|
||||
|
||||
if (encryptResult.ok === false) {
|
||||
const error = `Encryption failed: ${encryptResult.error}`;
|
||||
enqueueSnackbar(error, { variant: 'error' });
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Step 6: Construct the SealedSecret object
|
||||
const sealedSecretData: any = {
|
||||
apiVersion: 'bitnami.com/v1alpha1',
|
||||
kind: 'SealedSecret',
|
||||
metadata: {
|
||||
name: request.name,
|
||||
namespace: request.namespace,
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
encryptedData: encryptResult.value,
|
||||
template: {
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add scope annotations
|
||||
if (request.scope === 'namespace-wide') {
|
||||
sealedSecretData.metadata.annotations['sealedsecrets.bitnami.com/namespace-wide'] =
|
||||
'true';
|
||||
} else if (request.scope === 'cluster-wide') {
|
||||
sealedSecretData.metadata.annotations['sealedsecrets.bitnami.com/cluster-wide'] = 'true';
|
||||
}
|
||||
|
||||
return Ok({
|
||||
sealedSecretData,
|
||||
certificateInfo: certInfo,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || 'Unknown encryption error';
|
||||
enqueueSnackbar(errorMsg, { variant: 'error' });
|
||||
return Err(errorMsg);
|
||||
} finally {
|
||||
setEncrypting(false);
|
||||
}
|
||||
},
|
||||
[enqueueSnackbar]
|
||||
);
|
||||
|
||||
return { encrypt, encrypting };
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Headlamp Sealed Secrets Plugin
|
||||
*
|
||||
* A comprehensive plugin for managing Bitnami Sealed Secrets in Kubernetes.
|
||||
* Provides UI for viewing, creating, and managing encrypted secrets.
|
||||
*
|
||||
* Features:
|
||||
* - List and detail views for SealedSecrets
|
||||
* - Client-side encryption using controller's public key
|
||||
* - Sealing keys management
|
||||
* - Secret decryption (via K8s Secret access)
|
||||
* - Integration with Headlamp's Secret detail view
|
||||
*
|
||||
* @see https://github.com/bitnami-labs/sealed-secrets
|
||||
*/
|
||||
|
||||
import {
|
||||
registerDetailsViewSection,
|
||||
registerPluginSettings,
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { ApiErrorBoundary, GenericErrorBoundary } from './components/ErrorBoundary';
|
||||
import { SealedSecretList } from './components/SealedSecretList';
|
||||
import { SealingKeysView } from './components/SealingKeysView';
|
||||
import { SecretDetailsSection } from './components/SecretDetailsSection';
|
||||
import { SettingsPage } from './components/SettingsPage';
|
||||
|
||||
/**
|
||||
* Register sidebar navigation
|
||||
*/
|
||||
|
||||
// Main "Sealed Secrets" entry
|
||||
registerSidebarEntry({
|
||||
parent: null,
|
||||
name: 'sealed-secrets',
|
||||
label: 'Sealed Secrets',
|
||||
icon: 'mdi:lock',
|
||||
url: '/sealedsecrets',
|
||||
});
|
||||
|
||||
// "All Sealed Secrets" child entry
|
||||
registerSidebarEntry({
|
||||
parent: 'sealed-secrets',
|
||||
name: 'sealed-secrets-list',
|
||||
label: 'All Sealed Secrets',
|
||||
url: '/sealedsecrets',
|
||||
});
|
||||
|
||||
// "Sealing Keys" child entry
|
||||
registerSidebarEntry({
|
||||
parent: 'sealed-secrets',
|
||||
name: 'sealing-keys',
|
||||
label: 'Sealing Keys',
|
||||
url: '/sealedsecrets/keys',
|
||||
});
|
||||
|
||||
/**
|
||||
* Register routes
|
||||
*/
|
||||
|
||||
// List view with optional detail drawer
|
||||
registerRoute({
|
||||
path: '/sealedsecrets/:namespace?/:name?',
|
||||
sidebar: 'sealed-secrets-list',
|
||||
component: () => (
|
||||
<ApiErrorBoundary>
|
||||
<SealedSecretList />
|
||||
</ApiErrorBoundary>
|
||||
),
|
||||
exact: true,
|
||||
name: 'sealedsecret',
|
||||
});
|
||||
|
||||
// Sealing keys view
|
||||
registerRoute({
|
||||
path: '/sealedsecrets/keys',
|
||||
sidebar: 'sealing-keys',
|
||||
component: () => (
|
||||
<ApiErrorBoundary>
|
||||
<SealingKeysView />
|
||||
</ApiErrorBoundary>
|
||||
),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Register integration with Secret detail view
|
||||
*
|
||||
* Adds a "Sealed Secret" section to Secrets that are owned by SealedSecrets
|
||||
*/
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
if (resource?.kind === 'Secret') {
|
||||
return (
|
||||
<GenericErrorBoundary>
|
||||
<SecretDetailsSection resource={resource} />
|
||||
</GenericErrorBoundary>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Register plugin settings
|
||||
*
|
||||
* Settings will appear in Settings → Plugins → sealed-secrets
|
||||
*/
|
||||
registerPluginSettings('sealed-secrets', SettingsPage, true);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Test setup for Vitest
|
||||
*
|
||||
* Provides global mocks and utilities for testing
|
||||
*/
|
||||
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
// Mock localStorage for tests
|
||||
const localStorageMock = {
|
||||
getItem: () => {
|
||||
return null;
|
||||
},
|
||||
setItem: () => {
|
||||
//noop
|
||||
},
|
||||
removeItem: () => {
|
||||
// noop
|
||||
},
|
||||
clear: () => {
|
||||
// noop
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.localStorage = localStorageMock as any;
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Unit tests for Result type helpers
|
||||
*
|
||||
* Tests the Result<T, E> type system for type-safe error handling
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Err, Ok, tryCatch, tryCatchAsync } from './types';
|
||||
|
||||
describe('Result type system', () => {
|
||||
describe('Ok', () => {
|
||||
it('should create a successful result', () => {
|
||||
const result = Ok(42);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create Ok with string value', () => {
|
||||
const result = Ok('success');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe('success');
|
||||
}
|
||||
});
|
||||
|
||||
it('should create Ok with object value', () => {
|
||||
const obj = { foo: 'bar', count: 42 };
|
||||
const result = Ok(obj);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual(obj);
|
||||
expect(result.value.foo).toBe('bar');
|
||||
}
|
||||
});
|
||||
|
||||
it('should create Ok with null value', () => {
|
||||
const result = Ok(null);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Err', () => {
|
||||
it('should create an error result', () => {
|
||||
const result = Err('Something went wrong');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error).toBe('Something went wrong');
|
||||
}
|
||||
});
|
||||
|
||||
it('should create Err with detailed error message', () => {
|
||||
const errorMsg = 'Failed to parse certificate: Invalid PEM format';
|
||||
const result = Err(errorMsg);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error).toBe(errorMsg);
|
||||
expect(result.error).toContain('certificate');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type narrowing', () => {
|
||||
it('should narrow type with result.ok === true', () => {
|
||||
const result = Ok(42);
|
||||
|
||||
if (result.ok === true) {
|
||||
// TypeScript should know result.value exists and is number
|
||||
const value: number = result.value;
|
||||
expect(value).toBe(42);
|
||||
} else {
|
||||
throw new Error('Should not reach here');
|
||||
}
|
||||
});
|
||||
|
||||
it('should narrow type with result.ok === false', () => {
|
||||
const result = Err('error message');
|
||||
|
||||
if (result.ok === false) {
|
||||
// TypeScript should know result.error exists and is string
|
||||
const error: string = result.error;
|
||||
expect(error).toBe('error message');
|
||||
} else {
|
||||
throw new Error('Should not reach here');
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with explicit boolean check', () => {
|
||||
const result = Ok('success');
|
||||
|
||||
// This pattern requires === for type narrowing
|
||||
if (result.ok === true) {
|
||||
expect(result.value).toBe('success');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryCatch', () => {
|
||||
it('should return Ok for successful function', () => {
|
||||
const result = tryCatch(() => {
|
||||
return 2 + 2;
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return Err for throwing function', () => {
|
||||
const result = tryCatch(() => {
|
||||
throw new Error('Calculation failed');
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error.message).toBe('Calculation failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle string throws', () => {
|
||||
const result = tryCatch(() => {
|
||||
throw 'String error';
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error.message).toBe('String error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle object throws', () => {
|
||||
const result = tryCatch(() => {
|
||||
throw { code: 'ERR_CUSTOM', message: 'Custom error' };
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve function return value', () => {
|
||||
const result = tryCatch(() => {
|
||||
const data = { id: 1, name: 'test' };
|
||||
return data;
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toEqual({ id: 1, name: 'test' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryCatchAsync', () => {
|
||||
it('should return Ok for successful async function', async () => {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
return await Promise.resolve('async success');
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe('async success');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return Err for rejected promise', async () => {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
throw new Error('Async operation failed');
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error.message).toBe('Async operation failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle async computation', async () => {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return 'delayed result';
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe('delayed result');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle promise rejection', async () => {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
return await Promise.reject(new Error('Rejected'));
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error.message).toBe('Rejected');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle async string throws', async () => {
|
||||
const result = await tryCatchAsync(async () => {
|
||||
throw 'Async string error';
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok === false) {
|
||||
expect(result.error.message).toBe('Async string error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pattern matching', () => {
|
||||
it('should work with if-else pattern', () => {
|
||||
const divide = (a: number, b: number) => {
|
||||
if (b === 0) {
|
||||
return Err('Division by zero');
|
||||
}
|
||||
return Ok(a / b);
|
||||
};
|
||||
|
||||
const result1 = divide(10, 2);
|
||||
expect(result1.ok).toBe(true);
|
||||
if (result1.ok) {
|
||||
expect(result1.value).toBe(5);
|
||||
}
|
||||
|
||||
const result2 = divide(10, 0);
|
||||
expect(result2.ok).toBe(false);
|
||||
if (result2.ok === false) {
|
||||
expect(result2.error).toBe('Division by zero');
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with early return pattern', () => {
|
||||
const processData = (input: string) => {
|
||||
if (!input) {
|
||||
return Err('Input is required');
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.length < 3) {
|
||||
return Err('Input too short');
|
||||
}
|
||||
|
||||
return Ok(trimmed.toUpperCase());
|
||||
};
|
||||
|
||||
expect(processData('').ok).toBe(false);
|
||||
expect(processData('ab').ok).toBe(false);
|
||||
|
||||
const result = processData('hello');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe('HELLO');
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with chained operations', () => {
|
||||
const parseNumber = (str: string) => {
|
||||
const num = parseInt(str, 10);
|
||||
if (isNaN(num)) {
|
||||
return Err('Not a number');
|
||||
}
|
||||
return Ok(num);
|
||||
};
|
||||
|
||||
const doubleIfEven = (num: number) => {
|
||||
if (num % 2 !== 0) {
|
||||
return Err('Number is odd');
|
||||
}
|
||||
return Ok(num * 2);
|
||||
};
|
||||
|
||||
const process = (str: string) => {
|
||||
const numResult = parseNumber(str);
|
||||
if (numResult.ok === false) {
|
||||
return Err(`Parse failed: ${numResult.error}`);
|
||||
}
|
||||
|
||||
const doubledResult = doubleIfEven(numResult.value);
|
||||
if (doubledResult.ok === false) {
|
||||
return Err(`Double failed: ${doubledResult.error}`);
|
||||
}
|
||||
|
||||
return Ok(doubledResult.value);
|
||||
};
|
||||
|
||||
const result1 = process('8');
|
||||
expect(result1.ok).toBe(true);
|
||||
if (result1.ok) {
|
||||
expect(result1.value).toBe(16);
|
||||
}
|
||||
|
||||
const result2 = process('7');
|
||||
expect(result2.ok).toBe(false);
|
||||
|
||||
const result3 = process('abc');
|
||||
expect(result3.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* TypeScript interfaces for Bitnami Sealed Secrets plugin
|
||||
*/
|
||||
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
|
||||
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
|
||||
|
||||
/**
|
||||
* 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>>;
|
||||
|
||||
/**
|
||||
* Branded types for type-level security
|
||||
* These prevent mixing sensitive/non-sensitive values at compile time
|
||||
*/
|
||||
|
||||
/** Unique symbol for branding plaintext values */
|
||||
declare const PlaintextBrand: unique symbol;
|
||||
|
||||
/** Unique symbol for branding encrypted values */
|
||||
declare const EncryptedBrand: unique symbol;
|
||||
|
||||
/** Unique symbol for branding base64-encoded values */
|
||||
declare const Base64Brand: unique symbol;
|
||||
|
||||
/** Unique symbol for branding PEM certificates */
|
||||
declare const PEMCertBrand: unique symbol;
|
||||
|
||||
/**
|
||||
* Plaintext sensitive value (not yet encrypted)
|
||||
* Must be explicitly created via PlaintextValue()
|
||||
*/
|
||||
export type PlaintextValue = string & { readonly [PlaintextBrand]: typeof PlaintextBrand };
|
||||
|
||||
/**
|
||||
* Encrypted value (already encrypted)
|
||||
* Created by encryption functions
|
||||
*/
|
||||
export type EncryptedValue = string & { readonly [EncryptedBrand]: typeof EncryptedBrand };
|
||||
|
||||
/**
|
||||
* Base64-encoded string
|
||||
* Created by base64 encoding functions
|
||||
*/
|
||||
export type Base64String = string & { readonly [Base64Brand]: typeof Base64Brand };
|
||||
|
||||
/**
|
||||
* PEM-encoded certificate
|
||||
* Created by certificate parsing functions
|
||||
*/
|
||||
export type PEMCertificate = string & { readonly [PEMCertBrand]: typeof PEMCertBrand };
|
||||
|
||||
/**
|
||||
* Create a branded plaintext value
|
||||
* Use this to mark user input as plaintext before encryption
|
||||
*
|
||||
* @example
|
||||
* const secret = PlaintextValue('my-password');
|
||||
*/
|
||||
export function PlaintextValue(value: string): PlaintextValue {
|
||||
return value as PlaintextValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded encrypted value
|
||||
* This is typically used by encryption functions
|
||||
*
|
||||
* @example
|
||||
* return Ok(EncryptedValue(encryptedString));
|
||||
*/
|
||||
export function EncryptedValue(value: string): EncryptedValue {
|
||||
return value as EncryptedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded base64 string
|
||||
*
|
||||
* @example
|
||||
* return Ok(Base64String(encoded));
|
||||
*/
|
||||
export function Base64String(value: string): Base64String {
|
||||
return value as Base64String;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded PEM certificate
|
||||
*
|
||||
* @example
|
||||
* return Ok(PEMCertificate(certPem));
|
||||
*/
|
||||
export function PEMCertificate(value: string): PEMCertificate {
|
||||
return value as PEMCertificate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a branded type to get the raw string
|
||||
* Use sparingly - only when you need the raw value
|
||||
*
|
||||
* @example
|
||||
* const rawValue = unwrap(plaintextValue);
|
||||
*/
|
||||
export function unwrap<T extends string>(value: T): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export type SealedSecretScope = 'strict' | 'namespace-wide' | 'cluster-wide';
|
||||
|
||||
/**
|
||||
* SealedSecret CRD spec
|
||||
*/
|
||||
export interface SealedSecretSpec {
|
||||
/** Map of key names to encrypted (base64-encoded) values */
|
||||
encryptedData: Record<string, string>;
|
||||
/** Metadata template for the resulting Secret */
|
||||
template?: {
|
||||
metadata?: {
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SealedSecret status condition
|
||||
*/
|
||||
export interface SealedSecretCondition {
|
||||
type: string;
|
||||
status: 'True' | 'False' | 'Unknown';
|
||||
lastTransitionTime?: string;
|
||||
lastUpdateTime?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SealedSecret CRD status
|
||||
*/
|
||||
export interface SealedSecretStatus {
|
||||
conditions?: SealedSecretCondition[];
|
||||
observedGeneration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete SealedSecret CRD interface
|
||||
*/
|
||||
export interface SealedSecretInterface extends KubeObjectInterface {
|
||||
spec: SealedSecretSpec;
|
||||
status?: SealedSecretStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin configuration stored in localStorage
|
||||
*/
|
||||
export interface PluginConfig {
|
||||
/** Controller deployment name */
|
||||
controllerName: string;
|
||||
/** Controller namespace */
|
||||
controllerNamespace: string;
|
||||
/** Controller service port */
|
||||
controllerPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default plugin configuration
|
||||
*/
|
||||
export const DEFAULT_CONFIG: PluginConfig = {
|
||||
controllerName: 'sealed-secrets-controller',
|
||||
controllerNamespace: 'kube-system',
|
||||
controllerPort: 8080,
|
||||
};
|
||||
|
||||
/**
|
||||
* Key-value pair for encryption dialog
|
||||
*/
|
||||
export interface SecretKeyValue {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encryption request parameters
|
||||
*/
|
||||
export interface EncryptionRequest {
|
||||
name: string;
|
||||
namespace: string;
|
||||
scope: SealedSecretScope;
|
||||
keyValues: SecretKeyValue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate information extracted from PEM certificate
|
||||
*/
|
||||
export interface CertificateInfo {
|
||||
/** Validity period start date */
|
||||
validFrom: Date;
|
||||
/** Validity period end date */
|
||||
validTo: Date;
|
||||
/** Whether certificate is currently expired */
|
||||
isExpired: boolean;
|
||||
/** Days until expiry (negative if expired) */
|
||||
daysUntilExpiry: number;
|
||||
/** Certificate issuer (formatted as DN string) */
|
||||
issuer: string;
|
||||
/** Certificate subject (formatted as DN string) */
|
||||
subject: string;
|
||||
/** SHA-256 fingerprint of certificate */
|
||||
fingerprint: string;
|
||||
/** Serial number of certificate */
|
||||
serialNumber: string;
|
||||
}
|
||||
Reference in New Issue
Block a user