Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4efe88cf6e | |||
| 0ded85fe23 | |||
| b08df4fb76 |
+4
-4
@@ -1,13 +1,13 @@
|
||||
# Artifact Hub package metadata file
|
||||
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-pkg.yml
|
||||
version: 0.2.10
|
||||
version: 0.2.12
|
||||
name: headlamp-sealed-secrets
|
||||
displayName: Sealed Secrets Plugin for Headlamp
|
||||
createdAt: "2026-02-12T00:00:00Z"
|
||||
description: A comprehensive Headlamp plugin for managing Bitnami Sealed Secrets with client-side encryption and RBAC-aware UI
|
||||
license: Apache-2.0
|
||||
homeURL: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
|
||||
appVersion: 0.2.10
|
||||
appVersion: 0.2.12
|
||||
containersImages:
|
||||
- name: sealed-secrets-controller
|
||||
image: docker.io/bitnami/sealed-secrets-controller:v0.24.0
|
||||
@@ -19,8 +19,8 @@ keywords:
|
||||
- encryption
|
||||
- security
|
||||
annotations:
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.10/headlamp-sealed-secrets-0.2.10.tar.gz"
|
||||
headlamp/plugin/archive-checksum: sha256:f78b772929d9e26dcbb34a52c233f79219ee010619ab34469e3c9bf12ee81435
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.12/headlamp-sealed-secrets-0.2.12.tar.gz"
|
||||
headlamp/plugin/archive-checksum: sha256:a269f578a94131dc85ae50aa7f828f0dfe4f2fcc95638879784e4cdc09c0c076
|
||||
headlamp/plugin/version-compat: ">=0.13.0"
|
||||
headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop"
|
||||
links:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "headlamp-sealed-secrets",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.12",
|
||||
"description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -35,6 +35,10 @@ abstract class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoun
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -95,7 +99,14 @@ export class CryptoErrorBoundary extends BaseErrorBoundary {
|
||||
variant="body2"
|
||||
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
>
|
||||
Error: {this.state.error.message}
|
||||
{(() => {
|
||||
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>
|
||||
@@ -143,7 +154,14 @@ export class ApiErrorBoundary extends BaseErrorBoundary {
|
||||
variant="body2"
|
||||
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
>
|
||||
Error: {this.state.error.message}
|
||||
{(() => {
|
||||
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>
|
||||
@@ -182,7 +200,14 @@ export class GenericErrorBoundary extends BaseErrorBoundary {
|
||||
variant="body2"
|
||||
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
>
|
||||
Error: {this.state.error.message}
|
||||
{(() => {
|
||||
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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 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 {
|
||||
@@ -13,7 +14,18 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
|
||||
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';
|
||||
@@ -46,7 +58,7 @@ function formatScope(scope: SealedSecretScope): string {
|
||||
*/
|
||||
export function SealedSecretDetail() {
|
||||
const { namespace, name } = useParams<{ namespace: string; name: string }>();
|
||||
const [sealedSecret] = SealedSecret.useGet(name, namespace);
|
||||
const [sealedSecret, error] = SealedSecret.useGet(name, namespace);
|
||||
const [secret] = K8s.ResourceClasses.Secret.useGet(name, namespace);
|
||||
const [decryptKey, setDecryptKey] = React.useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
@@ -62,6 +74,22 @@ export function SealedSecretDetail() {
|
||||
}
|
||||
}, [namespace]);
|
||||
|
||||
// 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 />;
|
||||
@@ -94,16 +122,41 @@ export function SealedSecretDetail() {
|
||||
}
|
||||
}, [sealedSecret, enqueueSnackbar]);
|
||||
|
||||
const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData || {});
|
||||
// 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 (
|
||||
<>
|
||||
<Box>
|
||||
<SectionBox
|
||||
title={
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<span>{sealedSecret.metadata.name}</span>
|
||||
<Box>
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open
|
||||
onClose={handleClose}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: { xs: '100%', sm: '600px', md: '800px' },
|
||||
maxWidth: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<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"
|
||||
@@ -131,15 +184,15 @@ export function SealedSecretDetail() {
|
||||
rows={[
|
||||
{
|
||||
name: 'Name',
|
||||
value: sealedSecret.metadata.name,
|
||||
value: String(sealedSecret.metadata.name || ''),
|
||||
},
|
||||
{
|
||||
name: 'Namespace',
|
||||
value: sealedSecret.metadata.namespace,
|
||||
value: String(sealedSecret.metadata.namespace || ''),
|
||||
},
|
||||
{
|
||||
name: 'Scope',
|
||||
value: formatScope(sealedSecret.scope),
|
||||
value: String(formatScope(sealedSecret.scope)),
|
||||
},
|
||||
{
|
||||
name: 'Sync Status',
|
||||
@@ -151,16 +204,18 @@ export function SealedSecretDetail() {
|
||||
},
|
||||
{
|
||||
name: 'Status Message',
|
||||
value: sealedSecret.syncMessage,
|
||||
value: String(sealedSecret.syncMessage || 'Unknown'),
|
||||
hide: !sealedSecret.syncCondition,
|
||||
},
|
||||
{
|
||||
name: 'Age',
|
||||
value: sealedSecret.getAge(),
|
||||
value: String(sealedSecret.getAge() || ''),
|
||||
},
|
||||
{
|
||||
name: 'Created',
|
||||
value: new Date(sealedSecret.metadata.creationTimestamp!).toLocaleString(),
|
||||
value: sealedSecret.metadata.creationTimestamp
|
||||
? new Date(sealedSecret.metadata.creationTimestamp).toLocaleString()
|
||||
: 'Unknown',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -207,16 +262,18 @@ export function SealedSecretDetail() {
|
||||
rows={[
|
||||
{
|
||||
name: 'Secret Type',
|
||||
value: sealedSecret.spec.template.type || 'Opaque',
|
||||
value: String(sealedSecret.spec.template.type || 'Opaque'),
|
||||
},
|
||||
{
|
||||
name: 'Labels',
|
||||
value: JSON.stringify(sealedSecret.spec.template.metadata?.labels || {}),
|
||||
value: String(JSON.stringify(sealedSecret.spec.template.metadata?.labels || {})),
|
||||
hide: !sealedSecret.spec.template.metadata?.labels,
|
||||
},
|
||||
{
|
||||
name: 'Annotations',
|
||||
value: JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {}),
|
||||
value: String(
|
||||
JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {})
|
||||
),
|
||||
hide: !sealedSecret.spec.template.metadata?.annotations,
|
||||
},
|
||||
]}
|
||||
@@ -234,7 +291,7 @@ export function SealedSecretDetail() {
|
||||
},
|
||||
{
|
||||
name: 'Keys',
|
||||
value: Object.keys(secret.data || {}).join(', '),
|
||||
value: String(Object.keys(secret.data || {}).join(', ') || 'None'),
|
||||
},
|
||||
{
|
||||
name: 'Link',
|
||||
@@ -242,8 +299,8 @@ export function SealedSecretDetail() {
|
||||
<Link
|
||||
routeName="secret"
|
||||
params={{
|
||||
namespace: secret.metadata.namespace,
|
||||
name: secret.metadata.name,
|
||||
namespace: String(secret.metadata.namespace || ''),
|
||||
name: String(secret.metadata.name || ''),
|
||||
}}
|
||||
>
|
||||
View Secret
|
||||
@@ -259,29 +316,30 @@ export function SealedSecretDetail() {
|
||||
</Box>
|
||||
)}
|
||||
</SectionBox>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{decryptKey && (
|
||||
<DecryptDialog
|
||||
sealedSecret={sealedSecret}
|
||||
secretKey={decryptKey}
|
||||
onClose={() => setDecryptKey(null)}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
} 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';
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,7 @@ function formatScope(scope: SealedSecretScope): string {
|
||||
* 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');
|
||||
@@ -159,6 +162,8 @@ export function SealedSecretList() {
|
||||
</SectionBox>
|
||||
|
||||
<EncryptDialog open={createDialogOpen} onClose={handleCloseDialog} />
|
||||
|
||||
{namespace && name && <SealedSecretDetail />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
import {
|
||||
registerDetailsViewSection,
|
||||
registerPluginSettings,
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { ApiErrorBoundary, GenericErrorBoundary } from './components/ErrorBoundary';
|
||||
import { SealedSecretDetail } from './components/SealedSecretDetail';
|
||||
import { SealedSecretList } from './components/SealedSecretList';
|
||||
import { SealingKeysView } from './components/SealingKeysView';
|
||||
import { SecretDetailsSection } from './components/SecretDetailsSection';
|
||||
@@ -56,21 +56,13 @@ registerSidebarEntry({
|
||||
url: '/sealedsecrets/keys',
|
||||
});
|
||||
|
||||
// "Settings" child entry
|
||||
registerSidebarEntry({
|
||||
parent: 'sealed-secrets',
|
||||
name: 'sealed-secrets-settings',
|
||||
label: 'Settings',
|
||||
url: '/sealedsecrets/settings',
|
||||
});
|
||||
|
||||
/**
|
||||
* Register routes
|
||||
*/
|
||||
|
||||
// List view
|
||||
// List view with optional detail drawer
|
||||
registerRoute({
|
||||
path: '/sealedsecrets',
|
||||
path: '/sealedsecrets/:namespace?/:name?',
|
||||
sidebar: 'sealed-secrets-list',
|
||||
component: () => (
|
||||
<ApiErrorBoundary>
|
||||
@@ -78,18 +70,6 @@ registerRoute({
|
||||
</ApiErrorBoundary>
|
||||
),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Detail view
|
||||
registerRoute({
|
||||
path: '/sealedsecrets/:namespace/:name',
|
||||
sidebar: 'sealed-secrets-list',
|
||||
component: () => (
|
||||
<ApiErrorBoundary>
|
||||
<SealedSecretDetail />
|
||||
</ApiErrorBoundary>
|
||||
),
|
||||
exact: true,
|
||||
name: 'sealedsecret',
|
||||
});
|
||||
|
||||
@@ -105,18 +85,6 @@ registerRoute({
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Settings page
|
||||
registerRoute({
|
||||
path: '/sealedsecrets/settings',
|
||||
sidebar: 'sealed-secrets-settings',
|
||||
component: () => (
|
||||
<GenericErrorBoundary>
|
||||
<SettingsPage />
|
||||
</GenericErrorBoundary>
|
||||
),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Register integration with Secret detail view
|
||||
*
|
||||
@@ -132,3 +100,18 @@ registerDetailsViewSection(({ resource }) => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Register plugin settings
|
||||
*
|
||||
* Settings will appear in Settings → Plugins → Sealed Secrets
|
||||
*/
|
||||
registerPluginSettings(
|
||||
'headlamp-sealed-secrets',
|
||||
() => (
|
||||
<GenericErrorBoundary>
|
||||
<SettingsPage />
|
||||
</GenericErrorBoundary>
|
||||
),
|
||||
true // Display save button
|
||||
);
|
||||
|
||||
@@ -101,7 +101,9 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
|
||||
if (!condition) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return condition.message || condition.reason || condition.status;
|
||||
// Ensure we always return a string, not an object
|
||||
const message = condition.message || condition.reason || condition.status;
|
||||
return String(message || 'Unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,21 +122,11 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
|
||||
}
|
||||
|
||||
const result = await tryCatchAsync(async () => {
|
||||
// Query the CRD to get available versions
|
||||
const response = await fetch(
|
||||
// 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'
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('SealedSecrets CRD not found. Please install Sealed Secrets on the cluster.');
|
||||
}
|
||||
const errorText = await response.text().catch(() => response.statusText);
|
||||
throw new Error(`Failed to fetch CRD (${response.status} ${response.statusText}): ${errorText}`);
|
||||
}
|
||||
|
||||
const crd = await response.json();
|
||||
|
||||
// Find the storage version (the version used for persistence)
|
||||
const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user