From 0494d1b1efd18df7e05e6fbc82c591c20bbb1528 Mon Sep 17 00:00:00 2001 From: Akos Eros Date: Tue, 21 Apr 2026 15:06:10 +0200 Subject: [PATCH 1/7] feat: improve UI secret loading code Remove some duplication to make maintaining easier --- .../tests/pattern-catalog-page.cy.ts | 60 ++-- console/src/api.ts | 98 +++--- console/src/components/InstallPatternPage.tsx | 300 ++++++------------ console/src/components/ManageSecretsPage.tsx | 198 ++---------- console/src/components/PatternCatalogPage.tsx | 282 ++++++++++------ .../SecretFormExpandableSections.tsx | 95 ++++++ .../SecretForm/VaultInjectionStatusAlert.tsx | 51 +++ .../src/components/UninstallPatternPage.tsx | 8 +- console/src/hooks/useVaultJobPolling.ts | 36 +++ console/src/vaultSecrets.ts | 46 +++ 10 files changed, 631 insertions(+), 543 deletions(-) create mode 100644 console/src/components/SecretForm/SecretFormExpandableSections.tsx create mode 100644 console/src/components/SecretForm/VaultInjectionStatusAlert.tsx create mode 100644 console/src/hooks/useVaultJobPolling.ts create mode 100644 console/src/vaultSecrets.ts diff --git a/console/integration-tests/tests/pattern-catalog-page.cy.ts b/console/integration-tests/tests/pattern-catalog-page.cy.ts index 74aaf2e8c..8625c092c 100644 --- a/console/integration-tests/tests/pattern-catalog-page.cy.ts +++ b/console/integration-tests/tests/pattern-catalog-page.cy.ts @@ -25,9 +25,11 @@ describe('Pattern Catalog Page', () => { it('pattern cards show tier labels', () => { visitCatalog(); - cy.get('.patterns-operator__card').first().within(() => { - cy.get('.pf-v6-c-label').should('exist'); - }); + cy.get('.patterns-operator__card') + .first() + .within(() => { + cy.get('.pf-v6-c-label').should('exist'); + }); }); it('at least one pattern card displays a description', () => { @@ -41,21 +43,21 @@ describe('Pattern Catalog Page', () => { it('pattern cards have external Docs and Repo links', () => { visitCatalog(); - cy.get('.patterns-operator__card-links').first().within(() => { - cy.contains('a', 'Docs') - .should('have.attr', 'target', '_blank') - .and('have.attr', 'href'); - cy.contains('a', 'Repo') - .should('have.attr', 'target', '_blank') - .and('have.attr', 'href'); - }); + cy.get('.patterns-operator__card-links') + .first() + .within(() => { + cy.contains('a', 'Docs').should('have.attr', 'target', '_blank').and('have.attr', 'href'); + cy.contains('a', 'Repo').should('have.attr', 'target', '_blank').and('have.attr', 'href'); + }); }); it('pattern cards have action buttons', () => { visitCatalog(); - cy.get('.patterns-operator__card-actions').first().within(() => { - cy.get('button').should('have.length.greaterThan', 0); - }); + cy.get('.patterns-operator__card-actions') + .first() + .within(() => { + cy.get('button').should('have.length.greaterThan', 0); + }); }); it('tier filter dropdown shows all tier options', () => { @@ -71,24 +73,28 @@ describe('Pattern Catalog Page', () => { it('selecting all tiers shows at least as many cards as maintained only', () => { visitCatalog(); - cy.get('.patterns-operator__card').its('length').then((maintainedCount) => { - // Open filter and add Tested - cy.contains('button', 'Maintained').click(); - cy.contains('Tested').click(); - // Dropdown may close after selection; re-open to add Sandbox - cy.contains('button', /Maintained/).click(); - cy.contains('Sandbox').click(); - // Close dropdown - cy.get('body').click(0, 0); - // With more tiers selected, card count should be >= maintained only - cy.get('.patterns-operator__card').should('have.length.gte', maintainedCount); - }); + cy.get('.patterns-operator__card') + .its('length') + .then((maintainedCount) => { + // Open filter and add Tested + cy.contains('button', 'Maintained').click(); + cy.contains('Tested').click(); + // Dropdown may close after selection; re-open to add Sandbox + cy.contains('button', /Maintained/).click(); + cy.contains('Sandbox').click(); + // Close dropdown + cy.get('body').click(0, 0); + // With more tiers selected, card count should be >= maintained only + cy.get('.patterns-operator__card').should('have.length.gte', maintainedCount); + }); }); it('clicking Install navigates to the install page', () => { visitCatalog(); cy.get('body').then(($body) => { - const installBtn = $body.find('.patterns-operator__card-actions button:not(:disabled):contains("Install")'); + const installBtn = $body.find( + '.patterns-operator__card-actions button:not(:disabled):contains("Install")', + ); if (installBtn.length === 0) { cy.log('No Install button available (a pattern may already be installed)'); return; diff --git a/console/src/api.ts b/console/src/api.ts index 519c7882d..29891d248 100644 --- a/console/src/api.ts +++ b/console/src/api.ts @@ -8,9 +8,10 @@ declare const __PATTERN_OPERATOR_NS__: string; const DEFAULT_PATTERN_OPERATOR_NS = 'patterns-operator'; export const PATTERN_OPERATOR_NS = __PATTERN_OPERATOR_NS__ || DEFAULT_PATTERN_OPERATOR_NS; -const DEFAULT_PATTERN_UI_CATALOG_BASE_URL = '/api/proxy/plugin/patterns-operator-console-plugin/pattern-ui-catalog'; -const PATTERN_UI_CATALOG_BASE_URL = __PATTERN_UI_CATALOG_BASE_URL__ || DEFAULT_PATTERN_UI_CATALOG_BASE_URL; - +const DEFAULT_PATTERN_UI_CATALOG_BASE_URL = + '/api/proxy/plugin/patterns-operator-console-plugin/pattern-ui-catalog'; +const PATTERN_UI_CATALOG_BASE_URL = + __PATTERN_UI_CATALOG_BASE_URL__ || DEFAULT_PATTERN_UI_CATALOG_BASE_URL; async function fetchYAML(url: string): Promise { const response = await consoleFetch(url, { cache: 'no-store' }); @@ -28,9 +29,8 @@ export async function fetchPattern(name: string): Promise { export async function fetchCatalogImage(): Promise { try { - var response = await consoleFetch( + const response = await consoleFetch( `/api/kubernetes/apis/apps/v1/namespaces/${PATTERN_OPERATOR_NS}/deployments/patterns-operator-pattern-ui-catalog`, - ); const data = await response.json(); const containers = data.spec?.template?.spec?.containers || []; @@ -39,12 +39,14 @@ export async function fetchCatalogImage(): Promise { ); return catalogContainer?.image || 'unknown'; } catch (error) { - return 'unknown' + return 'unknown'; } - } -export async function fetchAllPatterns(): Promise<{ patterns: Pattern[]; catalogDescription?: string }> { +export async function fetchAllPatterns(): Promise<{ + patterns: Pattern[]; + catalogDescription?: string; +}> { const catalog = await fetchCatalog(); const patterns = await Promise.all( catalog.patterns.map(async (key) => { @@ -78,7 +80,9 @@ export interface VaultInjectionResponse { secretName?: string; } -export async function triggerVaultInjection(request: VaultInjectionRequest): Promise { +export async function triggerVaultInjection( + request: VaultInjectionRequest, +): Promise { try { console.log('🚀 [API] Starting vault injection for pattern:', request.patternName); console.log('📊 [API] Request details:', { @@ -87,7 +91,7 @@ export async function triggerVaultInjection(request: VaultInjectionRequest): Pro hasTemplate: !!request.templateYaml, vaultNamespace: request.vaultNamespace || 'vault', vaultPod: request.vaultPod || 'vault-0', - vaultHub: request.vaultHub || 'hub' + vaultHub: request.vaultHub || 'hub', }); const timestamp = Date.now(); @@ -122,18 +126,25 @@ export async function triggerVaultInjection(request: VaultInjectionRequest): Pro }; // Create the secret - console.log('🔐 [API] Creating secret with payload size:', JSON.stringify(secret).length, 'bytes'); + console.log( + '🔐 [API] Creating secret with payload size:', + JSON.stringify(secret).length, + 'bytes', + ); console.log('🔐 [API] Secret metadata:', { name: secret.metadata.name, namespace: secret.metadata.namespace, - labels: secret.metadata.labels + labels: secret.metadata.labels, }); - const secretResponse = await consoleFetch(`/api/kubernetes/api/v1/namespaces/${PATTERN_OPERATOR_NS}/secrets`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(secret), - }); + const secretResponse = await consoleFetch( + `/api/kubernetes/api/v1/namespaces/${PATTERN_OPERATOR_NS}/secrets`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(secret), + }, + ); if (!secretResponse.ok) { const errorText = await secretResponse.text(); @@ -146,7 +157,7 @@ export async function triggerVaultInjection(request: VaultInjectionRequest): Pro console.log('✅ [API] Secret creation result:', { name: secretResult.metadata?.name, uid: secretResult.metadata?.uid, - creationTimestamp: secretResult.metadata?.creationTimestamp + creationTimestamp: secretResult.metadata?.creationTimestamp, }); // Now create a Job that uses this secret @@ -329,14 +340,17 @@ PLAYBOOK_EOF namespace: job.metadata.namespace, labels: job.metadata.labels, serviceAccountName: job.spec.template.spec.serviceAccountName, - containerImage: job.spec.template.spec.containers[0].image + containerImage: job.spec.template.spec.containers[0].image, }); - const jobResponse = await consoleFetch(`/api/kubernetes/apis/batch/v1/namespaces/${PATTERN_OPERATOR_NS}/jobs`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(job), - }); + const jobResponse = await consoleFetch( + `/api/kubernetes/apis/batch/v1/namespaces/${PATTERN_OPERATOR_NS}/jobs`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(job), + }, + ); if (!jobResponse.ok) { const errorText = await jobResponse.text(); @@ -350,7 +364,7 @@ PLAYBOOK_EOF name: jobData.metadata?.name, uid: jobData.metadata?.uid, creationTimestamp: jobData.metadata?.creationTimestamp, - backoffLimit: jobData.spec?.backoffLimit + backoffLimit: jobData.spec?.backoffLimit, }); console.log('🎉 [API] Vault injection setup completed successfully'); @@ -365,7 +379,7 @@ PLAYBOOK_EOF console.error('🔴 [API] Error details:', { name: error.name, message: error.message, - stack: error.stack + stack: error.stack, }); return { success: false, @@ -382,18 +396,21 @@ export async function fetchVaultJobStatus(patternName: string): Promise ({ - name: job.metadata?.name, - creationTimestamp: job.metadata?.creationTimestamp, - status: job.status - })) || [] + items: + data.items?.map((job) => ({ + name: job.metadata?.name, + creationTimestamp: job.metadata?.creationTimestamp, + status: job.status, + })) || [], }); if (!data.items || data.items.length === 0) { @@ -412,7 +429,9 @@ export async function fetchVaultJobStatus(patternName: string): Promise ({ type: c.type, status: c.status, reason: c.reason })) || [] + conditions: + jobStatus.conditions?.map((c) => ({ type: c.type, status: c.status, reason: c.reason })) || + [], }); let status: VaultJobStatus['status'] = 'pending'; @@ -448,7 +467,7 @@ export async function fetchVaultJobStatus(patternName: string): Promise { }; } catch (err) { // consoleFetch may throw on 404 instead of returning a response - if (err?.response?.status === 404 || err?.status === 404 || - (err?.message && /404|not found/i.test(err.message))) { + if ( + err?.response?.status === 404 || + err?.status === 404 || + (err?.message && /404|not found/i.test(err.message)) + ) { return { exists: false }; } throw err; @@ -531,7 +553,9 @@ export async function deletePattern(name: string): Promise { export async function fetchSecretTemplate(name: string): Promise { try { - return await fetchYAML(`${PATTERN_UI_CATALOG_BASE_URL}/${name}/values-secret.yaml.template`); + return await fetchYAML( + `${PATTERN_UI_CATALOG_BASE_URL}/${name}/values-secret.yaml.template`, + ); } catch { return null; // Template doesn't exist } diff --git a/console/src/components/InstallPatternPage.tsx b/console/src/components/InstallPatternPage.tsx index eb2d72d50..8a116c5dc 100644 --- a/console/src/components/InstallPatternPage.tsx +++ b/console/src/components/InstallPatternPage.tsx @@ -14,7 +14,6 @@ import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, - ExpandableSection, Form, FormGroup, Label, @@ -23,32 +22,21 @@ import { TextInput, Title, } from '@patternfly/react-core'; -import { - Table, - Thead, - Tbody, - Tr, - Th, - Td, -} from '@patternfly/react-table'; +import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table'; import { k8sCreate } from '@openshift-console/dynamic-plugin-sdk'; import { fetchPattern, fetchPatternCR, fetchSecretTemplate, - fetchVaultJobStatus, triggerVaultInjection as apiTriggerVaultInjection, PATTERN_OPERATOR_NS, PatternCRStatus, - VaultJobStatus, - VaultInjectionRequest } from '../api'; -import { SecretTemplate, SecretFormData, SecretDefinition, SecretField } from '../types'; -import { GenerateField } from './SecretForm/GenerateField'; -import { PromptField } from './SecretForm/PromptField'; -import { FileField } from './SecretForm/FileField'; -import { IniField } from './SecretForm/IniField'; -import { StaticField } from './SecretForm/StaticField'; +import { useVaultJobPolling } from '../hooks/useVaultJobPolling'; +import { buildVaultInjectionYaml } from '../vaultSecrets'; +import { SecretTemplate, SecretFormData } from '../types'; +import { SecretFormExpandableSections } from './SecretForm/SecretFormExpandableSections'; +import { VaultInjectionStatusAlert } from './SecretForm/VaultInjectionStatusAlert'; import './SecretForm/SecretForm.css'; const PatternModel = { @@ -85,8 +73,8 @@ export default function InstallPatternPage() { const [secretTemplate, setSecretTemplate] = React.useState(null); const [secretFormData, setSecretFormData] = React.useState({}); const [expandedSections, setExpandedSections] = React.useState>({}); - const [vaultJobStatus, setVaultJobStatus] = React.useState(null); - const [checkingVaultStatus, setCheckingVaultStatus] = React.useState(false); + const { vaultJobStatus, setVaultJobStatus, checkingVaultStatus, checkVaultJobStatus } = + useVaultJobPolling(patternName); const [patternStatus, setPatternStatus] = React.useState(null); React.useEffect(() => { @@ -97,7 +85,7 @@ export default function InstallPatternPage() { console.log('🟢 [InstallPatternPage] Pattern data loaded successfully:', { patternName: patternData.name, repoUrl: patternData.repo_url, - hasSecretTemplate: !!template + hasSecretTemplate: !!template, }); setPatternName(patternData.name); @@ -114,7 +102,9 @@ export default function InstallPatternPage() { const initialExpanded: Record = {}; template.secrets.forEach((secret, index) => { - console.log(`🔧 [InstallPatternPage] Processing secret: ${secret.name} with ${secret.fields.length} fields`); + console.log( + `🔧 [InstallPatternPage] Processing secret: ${secret.name} with ${secret.fields.length} fields`, + ); initialData[secret.name] = {}; secret.fields.forEach((field) => { initialData[secret.name][field.name] = ''; @@ -126,7 +116,7 @@ export default function InstallPatternPage() { console.log('🔧 [InstallPatternPage] Secret form initialized:', { secretCount: template.secrets.length, initialData: Object.keys(initialData), - expandedSections: Object.keys(initialExpanded).filter(key => initialExpanded[key]) + expandedSections: Object.keys(initialExpanded).filter((key) => initialExpanded[key]), }); setSecretFormData(initialData); @@ -157,52 +147,21 @@ export default function InstallPatternPage() { console.log('✅ [InstallPatternPage] All required data present for vault injection:', { patternName, secretDataKeys: Object.keys(secretFormData), - templateSecrets: secretTemplate.secrets.map(s => s.name) + templateSecrets: secretTemplate.secrets.map((s) => s.name), }); try { - // Convert secretFormData to YAML format with proper structure for vault_load_secrets - const yaml = await import('js-yaml'); - console.log('🔄 [InstallPatternPage] Converting secretFormData to YAML:', secretFormData); - - // Build the v2.0 secrets list structure expected by parse_secrets_info - const secretsList = secretTemplate.secrets.map((secretDef) => { - const formValues = secretFormData[secretDef.name] || {}; - const secret: any = { name: secretDef.name }; - if (secretDef.vaultMount) secret.vaultMount = secretDef.vaultMount; - if (secretDef.vaultPrefixes) secret.vaultPrefixes = secretDef.vaultPrefixes; - secret.fields = secretDef.fields.map((fieldDef) => { - const field: any = { name: fieldDef.name }; - if (fieldDef.onMissingValue) field.onMissingValue = fieldDef.onMissingValue; - if (fieldDef.vaultPolicy) field.vaultPolicy = fieldDef.vaultPolicy; - if (fieldDef.base64) field.base64 = fieldDef.base64; - if (fieldDef.override) field.override = fieldDef.override; - const val = formValues[fieldDef.name]; - if (typeof val === 'string' && val !== '') { - field.value = val; - // User provided an explicit value, so don't auto-generate - if (fieldDef.onMissingValue === 'generate') { - delete field.onMissingValue; - delete field.vaultPolicy; - } - } - return field; - }); - return secret; - }); - - const vaultSecretStructure: SecretTemplate = { - version: '2.0', - secrets: secretsList, - vaultPolicies: secretTemplate?.vaultPolicies || null - }; - - const valuesSecretYaml = yaml.dump(vaultSecretStructure); - const templateYaml = JSON.stringify(secretTemplate, null, 2); - console.log('✅ [InstallPatternPage] Generated values YAML with vault structure:', valuesSecretYaml); + const { valuesSecretYaml, templateYaml } = await buildVaultInjectionYaml( + secretTemplate, + secretFormData, + ); + console.log( + '✅ [InstallPatternPage] Generated values YAML with vault structure:', + valuesSecretYaml, + ); console.log('✅ [InstallPatternPage] Generated template YAML:', templateYaml); - const request: VaultInjectionRequest = { + const request = { patternName, valuesSecretYaml, templateYaml, @@ -213,7 +172,9 @@ export default function InstallPatternPage() { console.log('📥 [InstallPatternPage] Vault injection result:', result); if (result.success) { - console.log('✅ [InstallPatternPage] Vault injection triggered successfully, starting job status polling'); + console.log( + '✅ [InstallPatternPage] Vault injection triggered successfully, starting job status polling', + ); // Start polling for job status setTimeout(() => { checkVaultJobStatus(); @@ -235,40 +196,13 @@ export default function InstallPatternPage() { } }, [patternName, secretFormData, secretTemplate]); - const checkVaultJobStatus = React.useCallback(async () => { - if (!patternName) { - console.log('🟡 [InstallPatternPage] No pattern name for vault job status check'); - return; - } - - try { - console.log('🔍 [InstallPatternPage] Checking vault job status for pattern:', patternName); - setCheckingVaultStatus(true); - const status = await fetchVaultJobStatus(patternName); - console.log('📋 [InstallPatternPage] Vault job status received:', status); - setVaultJobStatus(status); - - // Continue polling if job is still running or pending - if (status.status === 'running' || status.status === 'pending') { - console.log('⏳ [InstallPatternPage] Job still in progress, will poll again in 5 seconds'); - setTimeout(() => { - checkVaultJobStatus(); - }, 5000); // Poll every 5 seconds - } else { - console.log('✅ [InstallPatternPage] Job finished with status:', status.status); - } - } catch (err) { - console.error('🔴 [InstallPatternPage] Error checking vault job status:', err); - } finally { - setCheckingVaultStatus(false); - } - }, [patternName]); - // Check vault job status on component mount if secrets were configured React.useEffect(() => { const hasSecretData = secretFormData && Object.keys(secretFormData).length > 0; if (success && hasSecretData && secretTemplate && patternName) { - console.log('⏰ [InstallPatternPage] Pattern created successfully with secrets, starting vault job status check'); + console.log( + '⏰ [InstallPatternPage] Pattern created successfully with secrets, starting vault job status check', + ); const timer = setTimeout(() => { checkVaultJobStatus(); }, 2000); // Wait 2 seconds after pattern creation @@ -282,7 +216,9 @@ export default function InstallPatternPage() { // Initial fetch after a short delay to let the reconciler start const initialTimer = setTimeout(() => { - fetchPatternCR(patternName).then(setPatternStatus).catch(() => {}); + fetchPatternCR(patternName) + .then(setPatternStatus) + .catch(() => undefined); }, 3000); const interval = setInterval(async () => { @@ -330,7 +266,7 @@ export default function InstallPatternPage() { targetRepo, targetRevision, hasSecrets, - secretCount: hasSecrets ? Object.keys(secretFormData).length : 0 + secretCount: hasSecrets ? Object.keys(secretFormData).length : 0, }); const patternData: { apiVersion: string; @@ -357,7 +293,10 @@ export default function InstallPatternPage() { }, }; - console.log('🔧 [InstallPatternPage] Creating Pattern CR with data:', JSON.stringify(patternData, null, 2)); + console.log( + '🔧 [InstallPatternPage] Creating Pattern CR with data:', + JSON.stringify(patternData, null, 2), + ); await k8sCreate({ model: PatternModel, @@ -389,7 +328,9 @@ export default function InstallPatternPage() { fieldName: string, value: string | File | null, ) => { - console.log(`🔄 [InstallPatternPage] Secret field changed: ${secretName}.${fieldName}`, { value: value instanceof File ? `[File: ${value.name}]` : value }); + console.log(`🔄 [InstallPatternPage] Secret field changed: ${secretName}.${fieldName}`, { + value: value instanceof File ? `[File: ${value.name}]` : value, + }); setSecretFormData((prev) => ({ ...prev, [secretName]: { @@ -407,41 +348,6 @@ export default function InstallPatternPage() { })); }; - const getFieldType = (field: SecretField): 'generate' | 'prompt' | 'file' | 'ini' | 'static' => { - if (field.onMissingValue === 'generate') return 'generate'; - if (field.path) return 'file'; - if (field.ini_file) return 'ini'; - if (field.value !== undefined && field.value !== null) return 'static'; - return 'prompt'; // Default to prompt for required fields - }; - - const renderField = (secret: SecretDefinition, field: SecretField) => { - const fieldType = getFieldType(field); - const value = secretFormData[secret.name]?.[field.name] || ''; - - const commonProps = { - field, - value, - onChange: (newValue: string | File | null) => - handleFieldChange(secret.name, field.name, newValue), - }; - - switch (fieldType) { - case 'generate': - return ; - case 'prompt': - return ; - case 'file': - return ; - case 'ini': - return ; - case 'static': - return ; - default: - return ; - } - }; - if (loading) { return ( @@ -474,7 +380,8 @@ export default function InstallPatternPage() {

{(() => { - const hasSecrets = secretTemplate && secretFormData && Object.keys(secretFormData).length > 0; + const hasSecrets = + secretTemplate && secretFormData && Object.keys(secretFormData).length > 0; const reconcileComplete = patternStatus?.lastStep === 'reconcile complete'; const vaultDone = !hasSecrets || vaultJobStatus?.status === 'succeeded'; if (reconcileComplete && vaultDone) { @@ -502,7 +409,9 @@ export default function InstallPatternPage() { {t('Current Step')}

- {!patternStatus.lastError && } + {!patternStatus.lastError && ( + + )} {patternStatus.lastStep}
@@ -520,7 +429,9 @@ export default function InstallPatternPage() { {patternStatus.applications && patternStatus.applications.length > 0 && (
- {t('Applications')} + + {t('Applications')} + @@ -536,12 +447,30 @@ export default function InstallPatternPage() { @@ -552,44 +481,37 @@ export default function InstallPatternPage() { )} - {(!patternStatus.applications || patternStatus.applications.length === 0) && !patternStatus.lastError && ( -
- - {t('Waiting for ArgoCD applications to be created...')} -
- )} + {(!patternStatus.applications || patternStatus.applications.length === 0) && + !patternStatus.lastError && ( +
+ + {t('Waiting for ArgoCD applications to be created...')} +
+ )} )} )} {/* Vault injection status */} - {success && secretFormData && Object.keys(secretFormData).length > 0 && secretTemplate && vaultJobStatus && ( - -
- {(vaultJobStatus.status === 'running' || vaultJobStatus.status === 'pending' || checkingVaultStatus) && ( - - )} - {vaultJobStatus.message} -
- {vaultJobStatus.jobName && ( -

- {t('Job')}: {vaultJobStatus.jobName} -

- )} -
- )} + {success && + secretFormData && + Object.keys(secretFormData).length > 0 && + secretTemplate && + vaultJobStatus && ( + + )} {submitError && ( {submitError} @@ -641,35 +563,13 @@ export default function InstallPatternPage() { {t('Configure secrets that will be injected into Vault for this pattern.')} -
- {secretTemplate.secrets.map((secret) => ( - toggleSection(secret.name)} - className="patterns-operator__secret-section" - > - - {secret.name} - - {secret.fields.map((field) => ( - - {renderField(secret, field)} - - ))} - - - - ))} -
+ )} diff --git a/console/src/components/ManageSecretsPage.tsx b/console/src/components/ManageSecretsPage.tsx index 18174c812..cf76b9f14 100644 --- a/console/src/components/ManageSecretsPage.tsx +++ b/console/src/components/ManageSecretsPage.tsx @@ -6,12 +6,7 @@ import { ActionGroup, Alert, Button, - Card, - CardBody, - CardTitle, - ExpandableSection, Form, - FormGroup, PageSection, Spinner, Title, @@ -19,18 +14,13 @@ import { import { fetchPattern, fetchSecretTemplate, - fetchVaultJobStatus, triggerVaultInjection as apiTriggerVaultInjection, - PATTERN_OPERATOR_NS, - VaultJobStatus, - VaultInjectionRequest, } from '../api'; -import { SecretTemplate, SecretFormData, SecretDefinition, SecretField } from '../types'; -import { GenerateField } from './SecretForm/GenerateField'; -import { PromptField } from './SecretForm/PromptField'; -import { FileField } from './SecretForm/FileField'; -import { IniField } from './SecretForm/IniField'; -import { StaticField } from './SecretForm/StaticField'; +import { useVaultJobPolling } from '../hooks/useVaultJobPolling'; +import { buildVaultInjectionYaml } from '../vaultSecrets'; +import { SecretTemplate, SecretFormData } from '../types'; +import { SecretFormExpandableSections } from './SecretForm/SecretFormExpandableSections'; +import { VaultInjectionStatusAlert } from './SecretForm/VaultInjectionStatusAlert'; import './SecretForm/SecretForm.css'; export default function ManageSecretsPage() { @@ -50,11 +40,10 @@ export default function ManageSecretsPage() { const [secretTemplate, setSecretTemplate] = React.useState(null); const [secretFormData, setSecretFormData] = React.useState({}); const [expandedSections, setExpandedSections] = React.useState>({}); - const [vaultJobStatus, setVaultJobStatus] = React.useState(null); - const [checkingVaultStatus, setCheckingVaultStatus] = React.useState(false); + const { vaultJobStatus, setVaultJobStatus, checkingVaultStatus, checkVaultJobStatus } = + useVaultJobPolling(patternName); React.useEffect(() => { - Promise.all([fetchPattern(name), fetchSecretTemplate(name)]) .then(([patternData, template]) => { setPatternName(patternData.name); @@ -86,26 +75,6 @@ export default function ManageSecretsPage() { }); }, [name]); - const checkVaultJobStatus = React.useCallback(async () => { - if (!patternName) return; - - try { - setCheckingVaultStatus(true); - const status = await fetchVaultJobStatus(patternName); - setVaultJobStatus(status); - - if (status.status === 'running' || status.status === 'pending') { - setTimeout(() => { - checkVaultJobStatus(); - }, 5000); - } - } catch (err) { - console.error('Error checking vault job status:', err); - } finally { - setCheckingVaultStatus(false); - } - }, [patternName]); - const handleSubmit = async () => { setSubmitting(true); setSubmitError(null); @@ -113,42 +82,12 @@ export default function ManageSecretsPage() { setVaultJobStatus(null); try { - const yaml = await import('js-yaml'); - - const secretsList = secretTemplate.secrets.map((secretDef) => { - const formValues = secretFormData[secretDef.name] || {}; - const secret: any = { name: secretDef.name }; - if (secretDef.vaultMount) secret.vaultMount = secretDef.vaultMount; - if (secretDef.vaultPrefixes) secret.vaultPrefixes = secretDef.vaultPrefixes; - secret.fields = secretDef.fields.map((fieldDef) => { - const field: any = { name: fieldDef.name }; - if (fieldDef.onMissingValue) field.onMissingValue = fieldDef.onMissingValue; - if (fieldDef.vaultPolicy) field.vaultPolicy = fieldDef.vaultPolicy; - if (fieldDef.base64) field.base64 = fieldDef.base64; - if (fieldDef.override) field.override = fieldDef.override; - const val = formValues[fieldDef.name]; - if (typeof val === 'string' && val !== '') { - field.value = val; - if (fieldDef.onMissingValue === 'generate') { - delete field.onMissingValue; - delete field.vaultPolicy; - } - } - return field; - }); - return secret; - }); + const { valuesSecretYaml, templateYaml } = await buildVaultInjectionYaml( + secretTemplate, + secretFormData, + ); - const vaultSecretStructure: SecretTemplate = { - version: '2.0', - secrets: secretsList, - vaultPolicies: secretTemplate?.vaultPolicies || null - }; - - const valuesSecretYaml = yaml.dump(vaultSecretStructure); - const templateYaml = JSON.stringify(secretTemplate, null, 2); - - const request: VaultInjectionRequest = { + const request = { patternName, valuesSecretYaml, templateYaml, @@ -192,41 +131,6 @@ export default function ManageSecretsPage() { })); }; - const getFieldType = (field: SecretField): 'generate' | 'prompt' | 'file' | 'ini' | 'static' => { - if (field.onMissingValue === 'generate') return 'generate'; - if (field.path) return 'file'; - if (field.ini_file) return 'ini'; - if (field.value !== undefined && field.value !== null) return 'static'; - return 'prompt'; - }; - - const renderField = (secret: SecretDefinition, field: SecretField) => { - const fieldType = getFieldType(field); - const value = secretFormData[secret.name]?.[field.name] || ''; - - const commonProps = { - field, - value, - onChange: (newValue: string | File | null) => - handleFieldChange(secret.name, field.name, newValue), - }; - - switch (fieldType) { - case 'generate': - return ; - case 'prompt': - return ; - case 'file': - return ; - case 'ini': - return ; - case 'static': - return ; - default: - return ; - } - }; - if (loading) { return ( @@ -264,9 +168,7 @@ export default function ManageSecretsPage() { {t('Manage Secrets')} - - {t('Manage Secrets for {{displayName}}', { displayName })} - + {t('Manage Secrets for {{displayName}}', { displayName })} {success && ( @@ -275,30 +177,11 @@ export default function ManageSecretsPage() {
)} {success && vaultJobStatus && ( - -
- {(vaultJobStatus.status === 'running' || vaultJobStatus.status === 'pending' || checkingVaultStatus) && ( - - )} - {vaultJobStatus.message} -
- {vaultJobStatus.jobName && ( -

- {t('Job')}: {vaultJobStatus.jobName} -

- )} -
+ vaultJobStatus={vaultJobStatus} + checkingVaultStatus={checkingVaultStatus} + /> )} {submitError && ( @@ -313,45 +196,20 @@ export default function ManageSecretsPage() { }} > - {t('Enter or update the secrets that will be injected into Vault for this pattern. Fields left empty will retain their existing values in Vault.')} + {t( + 'Enter or update the secrets that will be injected into Vault for this pattern. Fields left empty will retain their existing values in Vault.', + )} -
- {secretTemplate.secrets.map((secret) => ( - toggleSection(secret.name)} - className="patterns-operator__secret-section" - > - - {secret.name} - - {secret.fields.map((field) => ( - - {renderField(secret, field)} - - ))} - - - - ))} -
+ - )} {!isInstalled && ( - - - - - - - ); -} diff --git a/console/src/types.ts b/console/src/types.ts index 1f1386d34..7af57d86f 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -82,6 +82,22 @@ export interface SecretField { export interface SecretFormData { [secretName: string]: { - [fieldName: string]: string | File | null; + [fieldName: string]: string | null; }; } + +/** One user-uploaded file; becomes a dedicated Secret mounted under {@link VAULT_UPLOADS_MOUNT_PREFIX}. */ +export interface VaultInjectionFileArtifact { + /** Path segment under the uploads mount (unique per secret+field). */ + slug: string; + /** Raw file bytes, base64-encoded (valid for Kubernetes Secret `data`). */ + dataBase64: string; +} + +export interface VaultInjectionPayload { + valuesSecretYaml: string; + fileArtifacts: VaultInjectionFileArtifact[]; +} + +/** Mount path in the vault injection Job pod; must match paths emitted in values-secret.yaml for file fields. */ +export const VAULT_UPLOADS_MOUNT_PREFIX = '/vault-uploads'; diff --git a/console/src/vaultSecrets.ts b/console/src/vaultSecrets.ts index dbac0e87d..83edd202a 100644 --- a/console/src/vaultSecrets.ts +++ b/console/src/vaultSecrets.ts @@ -1,29 +1,62 @@ -import type { SecretFormData, SecretTemplate } from './types'; +import type { + SecretFormData, + SecretTemplate, + SecretField, + SecretDefinition, + VaultInjectionPayload, + VaultInjectionFileArtifact, +} from './types'; +import { VAULT_UPLOADS_MOUNT_PREFIX } from './types'; +import { Document } from 'yaml'; + +function isFileTemplateField(fieldDef: SecretField): boolean { + return Boolean(fieldDef.path); +} + +/** Slug used as relative path under {@link VAULT_UPLOADS_MOUNT_PREFIX} (projected volume `path`). */ +function fileUploadSlug(secretName: string, fieldName: string): string { + const raw = `${secretName}_${fieldName}`; + const slug = raw + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 200); + return slug || 'upload'; +} /** - * Build values-secret.yaml and template JSON for the vault injection API, + * Build values-secret.yaml plus file artifacts for separate Kubernetes Secrets, * from catalog template + user form values (same structure as vault_load_secrets / parse_secrets_info v2.0). */ -export async function buildVaultInjectionYaml( +export function buildVaultInjectionPayload( secretTemplate: SecretTemplate, secretFormData: SecretFormData, -): Promise<{ valuesSecretYaml: string; templateYaml: string }> { - const yaml = await import('js-yaml'); +): VaultInjectionPayload { + const fileArtifacts: VaultInjectionFileArtifact[] = []; const secretsList = secretTemplate.secrets.map((secretDef) => { const formValues = secretFormData[secretDef.name] || {}; - const secret: Record = { name: secretDef.name }; + const secret: SecretDefinition = { name: secretDef.name, fields: [] }; if (secretDef.vaultMount) secret.vaultMount = secretDef.vaultMount; if (secretDef.vaultPrefixes) secret.vaultPrefixes = secretDef.vaultPrefixes; secret.fields = secretDef.fields.map((fieldDef) => { - const field: Record = { name: fieldDef.name }; + const field: SecretField = { name: fieldDef.name }; if (fieldDef.onMissingValue) field.onMissingValue = fieldDef.onMissingValue; if (fieldDef.vaultPolicy) field.vaultPolicy = fieldDef.vaultPolicy; - if (fieldDef.base64) field.base64 = fieldDef.base64; if (fieldDef.override) field.override = fieldDef.override; const val = formValues[fieldDef.name]; - if (typeof val === 'string' && val !== '') { - field.value = val; + const hasVal = val !== null && val !== undefined && val !== ''; + + if (isFileTemplateField(fieldDef) && hasVal) { + const slug = fileUploadSlug(secretDef.name, fieldDef.name); + field.path = `${VAULT_UPLOADS_MOUNT_PREFIX}/${slug}`; + field.base64 = true; + fileArtifacts.push({ slug, dataBase64: val as string }); + return field; + } + + if (hasVal) { + field.value = val as string; if (fieldDef.onMissingValue === 'generate') { delete field.onMissingValue; delete field.vaultPolicy; @@ -34,13 +67,20 @@ export async function buildVaultInjectionYaml( return secret; }); + const vaultPolicies = secretTemplate.vaultPolicies; + const hasVaultPolicies = + vaultPolicies != null && + typeof vaultPolicies === 'object' && + Object.keys(vaultPolicies).length > 0; + const vaultSecretStructure: SecretTemplate = { version: '2.0', - secrets: secretsList as unknown as SecretTemplate['secrets'], - vaultPolicies: secretTemplate?.vaultPolicies || null, + secrets: secretsList, + ...(hasVaultPolicies ? { vaultPolicies } : {}), }; + const doc = new Document(vaultSecretStructure); + + const valuesSecretYaml = doc.toString({ lineWidth: 0 }); - const valuesSecretYaml = yaml.dump(vaultSecretStructure); - const templateYaml = JSON.stringify(secretTemplate, null, 2); - return { valuesSecretYaml, templateYaml }; + return { valuesSecretYaml, fileArtifacts }; } diff --git a/console/yarn.lock b/console/yarn.lock index 08604b51c..d57b89e92 100644 --- a/console/yarn.lock +++ b/console/yarn.lock @@ -7902,6 +7902,11 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@^2.8.3: + version "2.8.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" + integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" From 321164f8800aa742076bc025bf281c291735a778 Mon Sep 17 00:00:00 2001 From: Akos Eros Date: Wed, 29 Apr 2026 16:52:49 +0200 Subject: [PATCH 5/7] fix: ini field upload --- console/src/components/SecretForm/IniField.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/console/src/components/SecretForm/IniField.tsx b/console/src/components/SecretForm/IniField.tsx index a86b8429c..be3a7a25a 100644 --- a/console/src/components/SecretForm/IniField.tsx +++ b/console/src/components/SecretForm/IniField.tsx @@ -79,8 +79,8 @@ export const IniField: React.FC = ({ field, value, onChange }) => return result; }; - const handleFileInputChange = (file: File | null, filename: string) => { - setFilename(filename); + const handleFileInputChange = (_, file: File) => { + setFilename(file.name); if (!file) { setFileContent(''); @@ -162,9 +162,11 @@ export const IniField: React.FC = ({ field, value, onChange }) => <> = ({ field, value, onChange }) => )} {value && ( -
{app.name} {app.namespace} - -