diff --git a/.changeset/project-creation-wizard.md b/.changeset/project-creation-wizard.md new file mode 100644 index 000000000..c17da2352 --- /dev/null +++ b/.changeset/project-creation-wizard.md @@ -0,0 +1,10 @@ +--- +'@openchoreo/backstage-plugin': patch +'@openchoreo/backstage-plugin-common': patch +'@openchoreo/backstage-plugin-catalog-backend-module': patch +'@openchoreo/backstage-plugin-scaffolder-backend-module': patch +--- + +Add a per-ProjectType "Create Project" wizard, mirroring the Resource creation flow. + +Each `ProjectType` / `ClusterProjectType` now generates a scaffolder Template via `PtdToTemplateConverter`, surfaced under a new `?view=projects` browse view with a dedicated "Project" landing card. Selecting a type opens a wizard whose parameters step is driven by the type's `spec.parameters.openAPIV3Schema`, then creates the Project with `spec.type` and `spec.parameters` set via the extended `openchoreo:project:create` action (it falls back to the OpenChoreo API default when these are omitted, keeping the legacy path working). The catalog provider emits these templates during full sync and the event-delta path keeps them current. Replaces the static `create-openchoreo-project` template. diff --git a/app-config.production.yaml b/app-config.production.yaml index 5fdea9578..8946e0d04 100644 --- a/app-config.production.yaml +++ b/app-config.production.yaml @@ -103,10 +103,9 @@ catalog: target: /app/catalog-entities/org.yaml rules: - allow: [Group] - - type: file - target: /app/templates/create-openchoreo-project/template.yaml - rules: - - allow: [Template] + # Per-ProjectType "Create Project" wizards are generated by the + # OpenChoreoEntityProvider (PtdToTemplateConverter), so no static + # create-openchoreo-project Location is registered here. - type: file target: /app/templates/create-openchoreo-componenttype/template.yaml rules: diff --git a/app-config.yaml b/app-config.yaml index 5d56f3468..a6119e225 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -271,11 +271,9 @@ catalog: target: ../../catalog-entities/org.yaml rules: - allow: [Group] - # Local example template - - type: file - target: ../../templates/create-openchoreo-project/template.yaml - rules: - - allow: [Template] + # Per-ProjectType "Create Project" wizards are generated by the + # OpenChoreoEntityProvider (PtdToTemplateConverter), so no static + # create-openchoreo-project Location is registered here. - type: file target: ../../templates/create-openchoreo-componenttype/template.yaml rules: diff --git a/packages/app/src/components/catalog/useKindCreateConfig.ts b/packages/app/src/components/catalog/useKindCreateConfig.ts index db344dd8a..b06b7da1d 100644 --- a/packages/app/src/components/catalog/useKindCreateConfig.ts +++ b/packages/app/src/components/catalog/useKindCreateConfig.ts @@ -67,10 +67,7 @@ export function useKindCreateConfig(): KindCreateConfig | null { }; case 'system': return { - createPath: templateRoute({ - namespace: 'default', - templateName: 'create-openchoreo-project', - }), + createPath: `${scaffolderRoot}?view=projects`, buttonLabel: 'Create Project', canCreate: projectPerm.canCreate, loading: projectPerm.loading, diff --git a/packages/app/src/components/scaffolder/CustomTemplateListPage.tsx b/packages/app/src/components/scaffolder/CustomTemplateListPage.tsx index 5264cca78..eb5b4d4a2 100644 --- a/packages/app/src/components/scaffolder/CustomTemplateListPage.tsx +++ b/packages/app/src/components/scaffolder/CustomTemplateListPage.tsx @@ -12,6 +12,7 @@ import FilterListIcon from '@material-ui/icons/FilterList'; import ArrowBackIcon from '@material-ui/icons/ArrowBack'; import WidgetsOutlinedIcon from '@material-ui/icons/WidgetsOutlined'; import StorageOutlinedIcon from '@material-ui/icons/StorageOutlined'; +import AccountTreeOutlinedIcon from '@material-ui/icons/AccountTreeOutlined'; import { useRouteRef, useApp } from '@backstage/core-plugin-api'; import { DocsIcon, Page, Header, Content } from '@backstage/core-components'; import { @@ -72,11 +73,11 @@ import { CustomTemplateCard } from './CustomTemplateCard'; import { TemplateCardSkeletons } from './TemplateCardSkeleton'; import { useStyles } from './styles'; -// 'Resource' templates are presented as a meta-card on the landing view -// (mirrors 'Component') and the per-type templates are listed under -// /create?view=resources. Hide them from the landing application-templates -// grid to avoid duplicate "Resource" cards. -const APPLICATION_TYPES = ['System (Project)']; +// 'Project', 'Component' and 'Resource' are all presented as meta-cards on the +// landing view; their per-type templates are listed under +// /create?view=projects|components|resources. None of them appear as real +// application-template cards, so APPLICATION_TYPES is empty. +const APPLICATION_TYPES: string[] = []; // Order is significant — this list controls the on-screen order of the // Platform Resources cards (see platformTemplates sort below). Foundation- // first to mirror Application Resources (Project → Component → Resource), @@ -98,11 +99,13 @@ const PLATFORM_TYPES = [ 'ClusterWorkflow', 'Workflow', ]; -// 'Resource' is intentionally in KNOWN_CARD_TYPES but NOT in APPLICATION_TYPES -// — per-type Resource templates are rendered under /create?view=resources, -// not in the landing grid or the "Other Templates" catch-all. +// 'Project'/'Component'/'Resource' are in KNOWN_CARD_TYPES but NOT in +// APPLICATION_TYPES — their per-type templates are rendered under the +// respective ?view= pages, not in the landing grid or the "Other Templates" +// catch-all. const KNOWN_CARD_TYPES = [ ...APPLICATION_TYPES, + 'Project', 'Component', 'Resource', ...PLATFORM_TYPES, @@ -142,6 +145,7 @@ const TemplateListContent = (props: TemplateListPageProps) => { const [searchParams, setSearchParams] = useSearchParams(); const isComponentsView = searchParams.get('view') === 'components'; const isResourcesView = searchParams.get('view') === 'resources'; + const isProjectsView = searchParams.get('view') === 'projects'; const { templateFilter, headerOptions } = props; @@ -184,7 +188,7 @@ const TemplateListContent = (props: TemplateListPageProps) => { const isTemplateDisabled = useCallback( (specType: string): boolean => { switch (specType) { - case 'System (Project)': + case 'Project': return !projectPerm.loading && !projectPerm.canCreate; case 'Component': return !componentPerm.loading && !componentPerm.canCreate; @@ -365,6 +369,19 @@ const TemplateListContent = (props: TemplateListPageProps) => { }, ]; + // Project Templates view shows the per-type wizards generated by + // PtdToTemplateConverter from (Cluster)ProjectType entities. The + // PTD_GENERATED annotation distinguishes them from any standalone + // hand-written Project template. + const projectGroups = [ + { + title: 'Project Templates', + filter: (e: any) => + e.spec?.type === 'Project' && + e.metadata?.annotations?.[CHOREO_ANNOTATIONS.PTD_GENERATED] === 'true', + }, + ]; + const resourceTemplates = useMemo( () => templates.filter( @@ -489,6 +506,10 @@ const TemplateListContent = (props: TemplateListPageProps) => { setSearchParams({ view: 'resources' }); }, [setSearchParams]); + const navigateToProjectsView = useCallback(() => { + setSearchParams({ view: 'projects' }); + }, [setSearchParams]); + const navigateBackToLanding = useCallback(() => { setSearchParams({}); }, [setSearchParams]); @@ -525,7 +546,7 @@ const TemplateListContent = (props: TemplateListPageProps) => { - {isComponentsView || isResourcesView ? ( + {isComponentsView || isResourcesView || isProjectsView ? ( ) : ( @@ -552,7 +573,7 @@ const TemplateListContent = (props: TemplateListPageProps) => { - {isComponentsView || isResourcesView ? ( + {isComponentsView || isResourcesView || isProjectsView ? ( ) : ( @@ -572,6 +593,37 @@ const TemplateListContent = (props: TemplateListPageProps) => { ); const renderLandingView = () => { + const projectDisabled = isTemplateDisabled('Project'); + const projectCard = ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigateToProjectsView(); + } + } + } + role="button" + tabIndex={projectDisabled ? -1 : 0} + aria-disabled={projectDisabled || undefined} + > + + + + Project + + Browse project templates + + + ); + const componentDisabled = isTemplateDisabled('Component'); const componentCard = ( { ) : ( renderTemplateCards(applicationTemplates) )} + {/* Project — navigation card, opens the per-type templates list */} + + {projectDisabled ? ( + + {projectCard} + + ) : ( + projectCard + )} + {/* Component — navigation card, no single backing template */} {componentDisabled ? ( @@ -745,6 +807,27 @@ const TemplateListContent = (props: TemplateListPageProps) => { return Card; }, [getResourceTemplateCardState]); + // Project create permission is namespace-scoped and generic (there is no + // per-projectType create permission), so the card is gated by the single + // project:create decision rather than a per-type context check. + const ProjectTemplateCard = useMemo(() => { + const projectDisabled = !projectPerm.loading && !projectPerm.canCreate; + const Card = (cardProps: { + template: TemplateEntityV1beta3; + onSelected?: (template: TemplateEntityV1beta3) => void; + }) => ( + + ); + Card.displayName = 'ProjectTemplateCard'; + return Card; + }, [projectPerm]); + const renderComponentsView = () => ( <> { ); + const renderProjectsView = () => ( + <> + + + Back to Resources + + + + + + ); + return (
{ {(() => { if (isComponentsView) return renderComponentsView(); if (isResourcesView) return renderResourcesView(); + if (isProjectsView) return renderProjectsView(); return renderLandingView(); })()} diff --git a/packages/app/src/components/scaffolder/OpenChoreoScaffolderPage.tsx b/packages/app/src/components/scaffolder/OpenChoreoScaffolderPage.tsx index 4f81851dd..3af30785c 100644 --- a/packages/app/src/components/scaffolder/OpenChoreoScaffolderPage.tsx +++ b/packages/app/src/components/scaffolder/OpenChoreoScaffolderPage.tsx @@ -21,6 +21,7 @@ import { ResourceTypeYamlEditorFieldExtension } from '../../scaffolder/ResourceT import { ClusterProjectTypeYamlEditorFieldExtension } from '../../scaffolder/ClusterProjectTypeYamlEditor'; import { ProjectTypeYamlEditorFieldExtension } from '../../scaffolder/ProjectTypeYamlEditor'; import { ResourceParametersFieldExtension } from '../../scaffolder/ResourceParametersField'; +import { ProjectParametersFieldExtension } from '../../scaffolder/ProjectParametersField'; import { ClusterTraitYamlEditorFieldExtension } from '../../scaffolder/ClusterTraitYamlEditor'; import { ComponentWorkflowYamlEditorFieldExtension } from '../../scaffolder/ComponentWorkflowYamlEditor'; import { ClusterWorkflowYamlEditorFieldExtension } from '../../scaffolder/ClusterWorkflowYamlEditor'; @@ -80,6 +81,7 @@ export function OpenChoreoScaffolderPage() { + diff --git a/packages/app/src/scaffolder/CustomReviewState/CustomReviewStep.test.tsx b/packages/app/src/scaffolder/CustomReviewState/CustomReviewStep.test.tsx new file mode 100644 index 000000000..20ef2abb6 --- /dev/null +++ b/packages/app/src/scaffolder/CustomReviewState/CustomReviewStep.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; +import type { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; +import { CustomReviewStep } from './CustomReviewStep'; + +// Stub StructuredMetadataTable so we can read exactly what each review +// section forwards as its `metadata` prop. +jest.mock('@backstage/core-components', () => ({ + StructuredMetadataTable: (props: { metadata: Record }) => ( +
+ ), +})); + +jest.mock('./styles', () => ({ + useStyles: () => ({ + reviewContent: 'reviewContent', + sectionTitle: 'sectionTitle', + footer: 'footer', + promotionPathRow: 'promotionPathRow', + envBox: 'envBox', + arrow: 'arrow', + }), +})); + +const makeProps = (formData: Record): ReviewStepProps => + ({ + formData, + steps: [], + handleBack: jest.fn(), + handleCreate: jest.fn(), + disableButtons: false, + } as unknown as ReviewStepProps); + +const tables = () => + screen + .getAllByTestId('smt') + .map(el => JSON.parse(el.getAttribute('data-metadata') || '{}')); + +describe('CustomReviewStep — Project (per-ProjectType) templates', () => { + it('renders Project Metadata and Parameters sections from a project template', () => { + render( + , + ); + + expect(screen.getByText('Project Metadata')).toBeInTheDocument(); + expect(screen.getByText('Parameters')).toBeInTheDocument(); + + const all = tables(); + expect(all).toHaveLength(2); + + // Metadata table: namespace ref is shortened to its name. + const meta = JSON.stringify(all[0]); + expect(meta).toContain('default'); + expect(meta).toContain('web-app-demo'); + expect(meta).toContain('Web App Demo'); + + // Parameters table: schema-driven values are flattened in. + expect(JSON.stringify(all[1])).toContain('my-app'); + }); + + it('omits the Parameters section for a type with no parameters', () => { + render( + , + ); + + expect(screen.getByText('Project Metadata')).toBeInTheDocument(); + expect(screen.queryByText('Parameters')).not.toBeInTheDocument(); + expect(tables()).toHaveLength(1); + }); +}); diff --git a/packages/app/src/scaffolder/CustomReviewState/CustomReviewStep.tsx b/packages/app/src/scaffolder/CustomReviewState/CustomReviewStep.tsx index c254b3055..e0579251f 100644 --- a/packages/app/src/scaffolder/CustomReviewState/CustomReviewStep.tsx +++ b/packages/app/src/scaffolder/CustomReviewState/CustomReviewStep.tsx @@ -584,6 +584,65 @@ function ResourceReview({ data }: { data: Record }) { ); } +// --------------------------------------------------------------------------- +// ProjectReview +// --------------------------------------------------------------------------- + +function ProjectReview({ data }: { data: Record }) { + const classes = useStyles(); + + // Section: Project Metadata + const projectMeta: Record = {}; + if (data.namespace_name) { + setMeta(projectMeta, 'Namespace', extractName(String(data.namespace_name))); + } + if (data.project_name) { + setMeta(projectMeta, 'Project Name', String(data.project_name)); + } + if (data.displayName) { + setMeta(projectMeta, 'Display Name', String(data.displayName)); + } + if (data.description) { + setMeta(projectMeta, 'Description', String(data.description)); + } + if (data.deployment_pipeline) { + setMeta( + projectMeta, + 'Deployment Pipeline', + String(data.deployment_pipeline), + ); + } + + // Section: Parameters — the per-type schema-driven form values flattened + // to label/value rows. Empty/missing fields are dropped by flattenToMetadata. + let parametersMeta: Record | undefined; + const parameters = data.parameters as Record | undefined; + if (parameters && Object.keys(parameters).length > 0) { + parametersMeta = {}; + flattenToMetadata(parameters, '', parametersMeta); + } + + return ( + <> + {Object.keys(projectMeta).length > 0 && ( + <> + + Project Metadata + + + + )} + + {parametersMeta && Object.keys(parametersMeta).length > 0 && ( + <> + Parameters + + + )} + + ); +} + // --------------------------------------------------------------------------- // DefaultReview (fallback using Backstage ReviewState) // --------------------------------------------------------------------------- @@ -620,6 +679,7 @@ function detectTemplateType( | 'environment' | 'component' | 'resource' + | 'project' | 'default' { if ('deploymentPipelineConfig' in formData) return 'deployment-pipeline'; if ('environmentConfig' in formData) return 'environment'; @@ -627,6 +687,8 @@ function detectTemplateType( // Per-type Resource templates emit `resource_name` at the top level // (vs Component's `component_name` which is paired with `workloadDetails`). if ('resource_name' in formData) return 'resource'; + // Per-type Project templates emit `project_name` at the top level. + if ('project_name' in formData) return 'project'; return 'default'; } @@ -655,6 +717,7 @@ export const CustomReviewStep = ({ )} {templateType === 'component' && } {templateType === 'resource' && } + {templateType === 'project' && } {templateType === 'default' && ( )} diff --git a/packages/app/src/scaffolder/ProjectParametersField/ProjectParametersField.test.tsx b/packages/app/src/scaffolder/ProjectParametersField/ProjectParametersField.test.tsx new file mode 100644 index 000000000..613ebeef9 --- /dev/null +++ b/packages/app/src/scaffolder/ProjectParametersField/ProjectParametersField.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from '@testing-library/react'; +import type { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react'; +import { ProjectParametersField } from './ProjectParametersField'; + +// Capture the props handed to RjsfForm so we can assert what schema/formData +// the field forwards and drive its onChange. +let rjsfProps: any = {}; + +jest.mock('@openchoreo/backstage-design-system', () => ({ + RjsfForm: (props: any) => { + rjsfProps = props; + return
; + }, +})); + +const makeProps = ( + uiOptions: Record, + overrides: Partial< + FieldExtensionComponentProps> + > = {}, +): FieldExtensionComponentProps> => + ({ + onChange: jest.fn(), + formData: undefined, + uiSchema: { 'ui:options': uiOptions }, + ...overrides, + } as unknown as FieldExtensionComponentProps>); + +const schemaWithFields = { + type: 'object', + properties: { replicas: { type: 'integer', title: 'Replicas' } }, +}; + +describe('ProjectParametersField', () => { + beforeEach(() => { + rjsfProps = {}; + }); + + it('renders the RJSF form and help text when the type has parameters', () => { + render( + , + ); + + expect(screen.getByTestId('rjsf-form')).toBeInTheDocument(); + expect( + screen.getByText(/Fill in the parameters defined by Web Application/), + ).toBeInTheDocument(); + expect(rjsfProps.schema).toEqual(schemaWithFields); + }); + + it('seeds the form with existing formData', () => { + render( + , + ); + expect(rjsfProps.formData).toEqual({ replicas: 3 }); + }); + + it('propagates RJSF onChange to the field onChange', () => { + const onChange = jest.fn(); + render( + , + ); + + rjsfProps.onChange({ formData: { replicas: 5 } }); + expect(onChange).toHaveBeenCalledWith({ replicas: 5 }); + + // A form change with no formData should fall back to an empty object. + rjsfProps.onChange({}); + expect(onChange).toHaveBeenLastCalledWith({}); + }); + + it('shows the empty-state when the type has no parameters schema', () => { + render( + , + ); + expect(screen.queryByTestId('rjsf-form')).not.toBeInTheDocument(); + expect( + screen.getByText('Minimal Type has no configurable parameters.'), + ).toBeInTheDocument(); + }); + + it('shows the empty-state when the schema declares no properties', () => { + render( + , + ); + expect(screen.queryByTestId('rjsf-form')).not.toBeInTheDocument(); + expect( + screen.getByText('Empty Type has no configurable parameters.'), + ).toBeInTheDocument(); + }); + + it('falls back to "this project" when no display name is provided', () => { + render(); + expect( + screen.getByText('this project has no configurable parameters.'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/scaffolder/ProjectParametersField/ProjectParametersField.tsx b/packages/app/src/scaffolder/ProjectParametersField/ProjectParametersField.tsx new file mode 100644 index 000000000..2f0de1d69 --- /dev/null +++ b/packages/app/src/scaffolder/ProjectParametersField/ProjectParametersField.tsx @@ -0,0 +1,81 @@ +import { Box, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react'; +import { RjsfForm } from '@openchoreo/backstage-design-system'; +import type { JSONSchema7 } from 'json-schema'; + +const useStyles = makeStyles(theme => ({ + helpText: { + color: theme.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + marginBottom: theme.spacing(1.5), + }, + emptyState: { + color: theme.palette.text.secondary, + fontStyle: 'italic', + }, +})); + +/** + * `ProjectParametersField` renders the parameters form for a per-type + * Project scaffolder template. The schema is supplied by + * `PtdToTemplateConverter` via `ui:options.ptdSchema` (read from the picked + * `(Cluster)ProjectType.spec.parameters.openAPIV3Schema` at template-generation + * time), so the field is purely a thin RJSF wrapper. Mirrors + * `ResourceParametersField`. + */ +export const ProjectParametersField = ({ + onChange, + formData, + uiSchema, +}: FieldExtensionComponentProps>) => { + const classes = useStyles(); + + const ptdSchema = uiSchema?.['ui:options']?.ptdSchema as + | JSONSchema7 + | undefined; + const ptdDisplayName = + typeof uiSchema?.['ui:options']?.ptdDisplayName === 'string' + ? uiSchema['ui:options'].ptdDisplayName + : 'this project'; + + const hasFields = + ptdSchema?.properties && Object.keys(ptdSchema.properties).length > 0; + + if (!hasFields) { + return ( + + + {`${ptdDisplayName} has no configurable parameters.`} + + + ); + } + + return ( + + + Fill in the parameters defined by {ptdDisplayName}. + + onChange(data.formData ?? {})} + tagName="div" + /> + + ); +}; + +/** + * Field-level JSON schema declared on this extension. The actual parameter + * schema is per-template (carried at runtime via `ui:options.ptdSchema`); + * at the extension level we just declare the field as an arbitrary object. + */ +export const ProjectParametersFieldSchema = { + returnValue: { + type: 'object' as const, + additionalProperties: true, + }, +}; diff --git a/packages/app/src/scaffolder/ProjectParametersField/extensions.ts b/packages/app/src/scaffolder/ProjectParametersField/extensions.ts new file mode 100644 index 000000000..b2b64bd8c --- /dev/null +++ b/packages/app/src/scaffolder/ProjectParametersField/extensions.ts @@ -0,0 +1,14 @@ +import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; +import { createScaffolderFieldExtension } from '@backstage/plugin-scaffolder-react'; +import { + ProjectParametersField, + ProjectParametersFieldSchema, +} from './ProjectParametersField'; + +export const ProjectParametersFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + name: 'ProjectParametersField', + component: ProjectParametersField, + schema: ProjectParametersFieldSchema, + }), +); diff --git a/packages/app/src/scaffolder/ProjectParametersField/index.ts b/packages/app/src/scaffolder/ProjectParametersField/index.ts new file mode 100644 index 000000000..4a5139c10 --- /dev/null +++ b/packages/app/src/scaffolder/ProjectParametersField/index.ts @@ -0,0 +1 @@ +export { ProjectParametersFieldExtension } from './extensions'; diff --git a/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.cluster.test.ts b/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.cluster.test.ts new file mode 100644 index 000000000..9d83e468e --- /dev/null +++ b/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.cluster.test.ts @@ -0,0 +1,141 @@ +import { + PtdToTemplateConverter, + ProjectTypeCRD, +} from './PtdToTemplateConverter'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; + +describe('PtdToTemplateConverter – convertClusterPtdToTemplateEntity', () => { + let converter: PtdToTemplateConverter; + + beforeEach(() => { + converter = new PtdToTemplateConverter({ defaultOwner: 'test-owner' }); + }); + + const baseCpt: ProjectTypeCRD = { + metadata: { + name: 'default', + displayName: 'Default Project', + description: 'The default cluster project type', + createdAt: '2026-06-01T10:00:00Z', + }, + spec: { + parameters: { + openAPIV3Schema: { + type: 'object', + properties: { + replicas: { type: 'integer', title: 'Replicas', default: 1 }, + }, + required: ['replicas'], + }, + }, + }, + }; + + it('produces a Template entity with correct apiVersion and kind', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + + expect(result.apiVersion).toBe('scaffolder.backstage.io/v1beta3'); + expect(result.kind).toBe('Template'); + }); + + it('uses openchoreo-cluster namespace instead of a user namespace', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + + expect(result.metadata.namespace).toBe('openchoreo-cluster'); + }); + + it('sets PTD_KIND annotation to ClusterProjectType', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + + expect(result.metadata.annotations?.[CHOREO_ANNOTATIONS.PTD_KIND]).toBe( + 'ClusterProjectType', + ); + }); + + it('sets standard PTD annotations (name, generated)', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + const annotations = result.metadata.annotations!; + + expect(annotations[CHOREO_ANNOTATIONS.PTD_NAME]).toBe('default'); + expect(annotations[CHOREO_ANNOTATIONS.PTD_GENERATED]).toBe('true'); + }); + + it('names the template template-project-', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + + expect(result.metadata.name).toBe('template-project-default'); + }); + + it('does not set PTD_DISPLAY_NAME when displayName is absent', () => { + const cpt: ProjectTypeCRD = { + ...baseCpt, + metadata: { ...baseCpt.metadata, displayName: undefined }, + }; + const result = converter.convertClusterPtdToTemplateEntity(cpt); + + expect( + result.metadata.annotations?.[CHOREO_ANNOTATIONS.PTD_DISPLAY_NAME], + ).toBeUndefined(); + }); + + it('does not pre-fill the namespace dropdown on cluster scope', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + const nsField = (result.spec as any).parameters[0].properties + .namespace_name; + + expect(nsField.default).toBeUndefined(); + }); + + it('generates 2 parameter sections (Project Metadata + Details)', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + const parameters = (result.spec as any).parameters as any[]; + + expect(parameters).toHaveLength(2); + expect(parameters[0].title).toBe('Project Metadata'); + expect(parameters[1].title).toBe('Default Project Details'); + }); + + it('passes ClusterProjectType to scaffolder step input typeKind', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + const steps = (result.spec as any).steps as any[]; + + expect(steps).toHaveLength(1); + expect(steps[0].input.typeKind).toBe('ClusterProjectType'); + expect(steps[0].input.typeName).toBe('default'); + }); + + it('passes PTD schema in parameters ui:options', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + const options = (result.spec as any).parameters[1].properties.parameters[ + 'ui:options' + ]; + + expect(options.ptdSchema).toBeDefined(); + expect(options.ptdSchema.properties.replicas.type).toBe('integer'); + expect(options.ptdSchema.required).toEqual(['replicas']); + expect(options.ptdKind).toBe('ClusterProjectType'); + }); + + it('uses guests as owner when no config provided', () => { + const defaultConverter = new PtdToTemplateConverter(); + const result = defaultConverter.convertClusterPtdToTemplateEntity(baseCpt); + expect(result.spec?.owner).toBe('guests'); + }); + + it('includes form decorator for user token injection', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + expect((result.spec as any).EXPERIMENTAL_formDecorators).toEqual([ + { id: 'openchoreo:inject-user-token' }, + ]); + }); + + it('includes output link with entity ref template', () => { + const result = converter.convertClusterPtdToTemplateEntity(baseCpt); + const output = (result.spec as any).output; + + expect(output.links).toHaveLength(1); + expect(output.links[0].entityRef).toContain( + "steps['create-project'].output", + ); + }); +}); diff --git a/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.test.ts b/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.test.ts new file mode 100644 index 000000000..247b3d952 --- /dev/null +++ b/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.test.ts @@ -0,0 +1,231 @@ +import { + PtdToTemplateConverter, + ProjectTypeCRD, +} from './PtdToTemplateConverter'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; + +describe('PtdToTemplateConverter', () => { + let converter: PtdToTemplateConverter; + + beforeEach(() => { + converter = new PtdToTemplateConverter({ defaultOwner: 'test-owner' }); + }); + + describe('convertPtdToTemplateEntity', () => { + it('converts a basic ProjectType to a scaffolder Template entity', () => { + const pt: ProjectTypeCRD = { + metadata: { + name: 'web-app', + displayName: 'Web Application', + description: 'A standard web application project', + createdAt: '2026-06-01T10:00:00Z', + }, + spec: { + parameters: { + openAPIV3Schema: { + type: 'object', + properties: { + replicas: { type: 'integer', title: 'Replicas', default: 1 }, + }, + required: ['replicas'], + }, + }, + }, + }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + + expect(result.apiVersion).toBe('scaffolder.backstage.io/v1beta3'); + expect(result.kind).toBe('Template'); + expect(result.metadata.name).toBe('template-project-web-app'); + expect(result.metadata.namespace).toBe('finance'); + expect(result.metadata.title).toBe('Web Application'); + expect(result.metadata.description).toBe( + 'A standard web application project', + ); + + expect(result.metadata.annotations?.[CHOREO_ANNOTATIONS.PTD_NAME]).toBe( + 'web-app', + ); + expect( + result.metadata.annotations?.[CHOREO_ANNOTATIONS.PTD_GENERATED], + ).toBe('true'); + expect(result.metadata.annotations?.[CHOREO_ANNOTATIONS.PTD_KIND]).toBe( + 'ProjectType', + ); + expect( + result.metadata.annotations?.[CHOREO_ANNOTATIONS.PTD_DISPLAY_NAME], + ).toBe('Web Application'); + + expect(result.spec?.owner).toBe('test-owner'); + expect(result.spec?.type).toBe('Project'); + expect((result.spec as any)?.EXPERIMENTAL_formDecorators).toEqual([ + { id: 'openchoreo:inject-user-token' }, + ]); + }); + + it('falls back to a formatted title when displayName is not provided', () => { + const pt: ProjectTypeCRD = { + metadata: { name: 'data-pipeline' }, + spec: {}, + }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + + expect(result.metadata.title).toBe('Data Pipeline'); + expect(result.metadata.description).toBe( + 'Create a Data Pipeline project', + ); + expect( + result.metadata.annotations?.[CHOREO_ANNOTATIONS.PTD_DISPLAY_NAME], + ).toBeUndefined(); + }); + + it('uses the provided description verbatim when present', () => { + const pt: ProjectTypeCRD = { + metadata: { name: 'svc', description: 'Backing services project.' }, + spec: {}, + }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + + expect(result.metadata.description).toBe('Backing services project.'); + }); + + it('uses the default owner "guests" when no config is passed', () => { + const defaultConverter = new PtdToTemplateConverter(); + const result = defaultConverter.convertPtdToTemplateEntity( + { metadata: { name: 'svc' }, spec: {} }, + 'finance', + ); + + expect(result.spec?.owner).toBe('guests'); + }); + + it('emits a Project Metadata section with namespace + project_name + deployment pipeline', () => { + const pt: ProjectTypeCRD = { + metadata: { name: 'web-app' }, + spec: {}, + }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + const params = (result.spec as any).parameters; + const metadataSection = params[0]; + + expect(metadataSection.title).toBe('Project Metadata'); + expect(metadataSection.required).toEqual([ + 'namespace_name', + 'project_name', + 'deployment_pipeline', + ]); + expect(metadataSection.properties.namespace_name['ui:field']).toBe( + 'NamespaceEntityPicker', + ); + expect(metadataSection.properties.project_name['ui:field']).toBe( + 'ResourceNamePicker', + ); + expect( + metadataSection.properties.project_name['ui:options'].catalogKind, + ).toBe('System'); + expect(metadataSection.properties.deployment_pipeline['ui:field']).toBe( + 'DeploymentPipelinePicker', + ); + }); + + it('pre-fills the namespace dropdown with the type own namespace (namespaced scope)', () => { + const pt: ProjectTypeCRD = { metadata: { name: 'web-app' }, spec: {} }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + const nsField = (result.spec as any).parameters[0].properties + .namespace_name; + + expect(nsField.default).toBe('domain:default/finance'); + }); + + it('emits a Details section with a ProjectParametersField pointing at the schema', () => { + const pt: ProjectTypeCRD = { + metadata: { name: 'web-app', displayName: 'Web Application' }, + spec: { + parameters: { + openAPIV3Schema: { + type: 'object', + properties: { replicas: { type: 'integer', title: 'Replicas' } }, + }, + }, + }, + }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + const detailsSection = (result.spec as any).parameters[1]; + + expect(detailsSection.title).toBe('Web Application Details'); + expect(detailsSection.properties.parameters['ui:field']).toBe( + 'ProjectParametersField', + ); + const uiOptions = detailsSection.properties.parameters['ui:options']; + expect(uiOptions.ptdName).toBe('web-app'); + expect(uiOptions.ptdKind).toBe('ProjectType'); + expect(uiOptions.ptdDisplayName).toBe('Web Application'); + expect(uiOptions.ptdSchema).toEqual({ + type: 'object', + properties: { replicas: { type: 'integer', title: 'Replicas' } }, + }); + }); + + it('omits ptdSchema in ui:options when the ProjectType has no schema', () => { + const pt: ProjectTypeCRD = { metadata: { name: 'simple' }, spec: {} }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + const uiOptions = (result.spec as any).parameters[1].properties + .parameters['ui:options']; + + expect(uiOptions.ptdSchema).toBeUndefined(); + }); + + it('wires steps[0] to openchoreo:project:create with structured input', () => { + const pt: ProjectTypeCRD = { metadata: { name: 'web-app' }, spec: {} }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + const steps = (result.spec as any).steps; + + expect(steps).toHaveLength(1); + expect(steps[0].id).toBe('create-project'); + expect(steps[0].action).toBe('openchoreo:project:create'); + expect(steps[0].input).toEqual({ + namespaceName: '${{ parameters.namespace_name }}', + projectName: '${{ parameters.project_name }}', + displayName: '${{ parameters.displayName }}', + description: '${{ parameters.description }}', + deploymentPipeline: '${{ parameters.deployment_pipeline }}', + typeKind: 'ProjectType', + typeName: 'web-app', + parameters: '${{ parameters.parameters }}', + }); + }); + + it('emits a View Project output link pointing at the created entity', () => { + const pt: ProjectTypeCRD = { metadata: { name: 'web-app' }, spec: {} }; + + const result = converter.convertPtdToTemplateEntity(pt, 'finance'); + const output = (result.spec as any).output; + + expect(output.links).toEqual([ + { + title: 'View Project', + icon: 'kind:system', + entityRef: + "system:${{ steps['create-project'].output.namespaceName }}/${{ steps['create-project'].output.projectName }}", + }, + ]); + }); + + it('formats hyphenated ProjectType names as title case', () => { + expect( + converter.convertPtdToTemplateEntity( + { metadata: { name: 'event-driven' }, spec: {} }, + 'finance', + ).metadata.title, + ).toBe('Event Driven'); + }); + }); +}); diff --git a/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.ts b/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.ts new file mode 100644 index 000000000..b2b15ae37 --- /dev/null +++ b/plugins/catalog-backend-module-openchoreo/src/converters/PtdToTemplateConverter.ts @@ -0,0 +1,275 @@ +import { Entity } from '@backstage/catalog-model'; +import { JSONSchema7 } from 'json-schema'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; + +/** + * Namespace used for cluster-scoped template entities (those generated + * from `ClusterProjectType` rather than namespace-scoped `ProjectType`). + * Matches the convention used by `RtdToTemplateConverter` / + * `CtdToTemplateConverter`. + */ +const CLUSTER_TEMPLATE_NAMESPACE = 'openchoreo-cluster'; + +/** + * (Cluster)ProjectType CRD shape consumed by the converter. Covers both + * namespace-scoped `ProjectType` and cluster-scoped `ClusterProjectType` + * — the only behavioural difference is the namespace the generated Template + * lives in (handled by the separate `convertClusterPtdToTemplateEntity` + * entrypoint). Unlike the Resource family, the `(Cluster)ProjectType` list + * endpoint returns the full `spec.parameters.openAPIV3Schema` inline, so no + * separate `/schema` fetch is needed. + */ +export interface ProjectTypeCRD { + metadata: { + name: string; + displayName?: string; + description?: string; + tags?: string[]; + createdAt?: string; + }; + spec: { + parameters?: { + openAPIV3Schema?: JSONSchema7; + }; + }; +} + +export interface PtdConverterConfig { + /** Default owner for generated templates (required by the Template kind schema). */ + defaultOwner?: string; +} + +type PtdKind = 'ProjectType' | 'ClusterProjectType'; + +/** + * Converts OpenChoreo (Cluster)ProjectType CRDs into Backstage scaffolder + * Template entities. Mirrors `RtdToTemplateConverter`: each ProjectType + * becomes a per-type Project-creation wizard listed under + * `/create?view=projects`. + * + * `PTD` (ProjectType Definition) is the umbrella term covering both scopes; + * the actual K8s kind lives on the generated Template entity's + * `openchoreo.io/ptd-kind` annotation as either `ProjectType` or + * `ClusterProjectType`. + * + * The generated template emits one schema-driven `parameters` field via the + * `ProjectParametersField` extension; the rendered form is built from + * `spec.parameters.openAPIV3Schema` at runtime. The Project itself is created + * by the `openchoreo:project:create` action, which sets `spec.type` + + * `spec.parameters` on the Project CR. + */ +export class PtdToTemplateConverter { + private readonly defaultOwner: string; + + constructor(config?: PtdConverterConfig) { + this.defaultOwner = config?.defaultOwner || 'guests'; + } + + /** Convert a namespace-scoped ProjectType to a scaffolder Template entity. */ + convertPtdToTemplateEntity( + pt: ProjectTypeCRD, + namespaceName: string, + ): Entity { + return this.buildTemplate(pt, namespaceName, 'ProjectType'); + } + + /** + * Convert a cluster-scoped ClusterProjectType to a scaffolder Template + * entity living in the `openchoreo-cluster` namespace. The developer picks + * the deployment namespace explicitly (no pre-filled default), since a + * cluster-scoped type can back Projects in any namespace. + */ + convertClusterPtdToTemplateEntity(cpt: ProjectTypeCRD): Entity { + return this.buildTemplate( + cpt, + CLUSTER_TEMPLATE_NAMESPACE, + 'ClusterProjectType', + ); + } + + private buildTemplate( + pt: ProjectTypeCRD, + templateNamespace: string, + ptdKind: PtdKind, + ): Entity { + const templateName = this.generateTemplateName(pt.metadata.name); + const title = pt.metadata.displayName || this.formatTitle(pt.metadata.name); + const description = pt.metadata.description || `Create a ${title} project`; + + // For a namespace-scoped ProjectType, pre-fill the namespace dropdown with + // the type's own namespace — a namespaced ProjectType can only back a + // Project in that same namespace. For a ClusterProjectType the developer + // picks any namespace, so no default is set. + const defaultNamespaceRef = + ptdKind === 'ClusterProjectType' + ? undefined + : `domain:default/${templateNamespace}`; + + const templateEntity: Entity = { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: templateName, + namespace: templateNamespace, + title, + description, + annotations: { + [CHOREO_ANNOTATIONS.PTD_NAME]: pt.metadata.name, + [CHOREO_ANNOTATIONS.PTD_GENERATED]: 'true', + [CHOREO_ANNOTATIONS.PTD_KIND]: ptdKind, + }, + }, + spec: { + owner: this.defaultOwner, + type: 'Project', + EXPERIMENTAL_formDecorators: [{ id: 'openchoreo:inject-user-token' }], + parameters: this.generateParameters( + pt, + defaultNamespaceRef, + ptdKind, + title, + ), + steps: this.generateSteps(pt, ptdKind), + output: { + links: [ + { + title: 'View Project', + icon: 'kind:system', + entityRef: + "system:${{ steps['create-project'].output.namespaceName }}/${{ steps['create-project'].output.projectName }}", + }, + ], + }, + } as any, + }; + + if (pt.metadata.displayName) { + templateEntity.metadata.annotations![ + CHOREO_ANNOTATIONS.PTD_DISPLAY_NAME + ] = pt.metadata.displayName; + } + + return templateEntity; + } + + /** Template name format: `template-project-`. */ + private generateTemplateName(ptName: string): string { + return `template-project-${ptName}`; + } + + /** Format `web-app` → `Web App`. */ + private formatTitle(name: string): string { + return name + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + /** + * Two parameter sections: + * 1. Project Metadata — namespace + project_name (+ display name / description / deployment pipeline) + * 2. Details — single `parameters` field rendered by `ProjectParametersField` + * which reads the schema from ui:options.ptdSchema + * + * Field names match the existing scaffolder field extensions: `namespace_name` + * (read by `DeploymentPipelinePicker` to scope its pipeline list) and + * `deployment_pipeline`. + */ + private generateParameters( + pt: ProjectTypeCRD, + defaultNamespaceRef: string | undefined, + ptdKind: PtdKind, + title: string, + ): any[] { + const namespaceProperty: Record = { + title: 'Namespace', + type: 'string', + description: 'Namespace where the project will be created', + 'ui:field': 'NamespaceEntityPicker', + }; + // Pre-fill for namespaced ProjectTypes; NamespaceEntityPicker skips its + // auto-select when formData is already populated from this default. + if (defaultNamespaceRef) { + namespaceProperty.default = defaultNamespaceRef; + } + + const metadataSection = { + title: 'Project Metadata', + required: ['namespace_name', 'project_name', 'deployment_pipeline'], + properties: { + namespace_name: namespaceProperty, + project_name: { + title: 'Project Name', + type: 'string', + description: + 'Unique name for your project (must be a valid Kubernetes name)', + 'ui:field': 'ResourceNamePicker', + 'ui:options': { + catalogKind: 'System', + resourceLabel: 'Project', + namespaceField: 'namespace_name', + }, + }, + displayName: { + title: 'Display Name', + type: 'string', + description: 'A human-readable display name for the Project', + }, + description: { + title: 'Description', + type: 'string', + description: 'Describe what this Project is for', + }, + deployment_pipeline: { + title: 'Deployment Pipeline', + type: 'string', + description: 'Deployment pipeline to associate with this project', + 'ui:field': 'DeploymentPipelinePicker', + }, + }, + }; + + const detailsUiOptions: Record = { + ptdName: pt.metadata.name, + ptdKind, + ptdDisplayName: title, + }; + if (pt.spec.parameters?.openAPIV3Schema) { + detailsUiOptions.ptdSchema = pt.spec.parameters.openAPIV3Schema; + } + + const detailsSection = { + title: `${title} Details`, + properties: { + parameters: { + title: 'Parameters', + type: 'object', + 'ui:field': 'ProjectParametersField', + 'ui:options': detailsUiOptions, + }, + }, + }; + + return [metadataSection, detailsSection]; + } + + private generateSteps(pt: ProjectTypeCRD, ptdKind: PtdKind): any[] { + return [ + { + id: 'create-project', + name: 'Create OpenChoreo Project', + action: 'openchoreo:project:create', + input: { + namespaceName: '${{ parameters.namespace_name }}', + projectName: '${{ parameters.project_name }}', + displayName: '${{ parameters.displayName }}', + description: '${{ parameters.description }}', + deploymentPipeline: '${{ parameters.deployment_pipeline }}', + typeKind: ptdKind, + typeName: pt.metadata.name, + parameters: '${{ parameters.parameters }}', + }, + }, + ]; + } +} diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts index 8d995606f..6ba40666f 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts @@ -3,6 +3,7 @@ import { ComponentTypeUtils } from '@openchoreo/backstage-plugin-common'; import { ConfigReader } from '@backstage/config'; import { CtdToTemplateConverter } from '../converters/CtdToTemplateConverter'; import { RtdToTemplateConverter } from '../converters/RtdToTemplateConverter'; +import { PtdToTemplateConverter } from '../converters/PtdToTemplateConverter'; import { EventDeltaApplier } from './EventDeltaApplier'; // --------------------------------------------------------------------------- @@ -52,6 +53,7 @@ function newApplier(connection: EntityProviderConnection) { getConnection: () => connection, ctdConverter: new CtdToTemplateConverter(mkLogger()), rtdConverter: new RtdToTemplateConverter(mkLogger()), + ptdConverter: new PtdToTemplateConverter(mkLogger()), }); } @@ -105,9 +107,13 @@ describe('EventDeltaApplier.handleEvent', () => { 'template:test-ns/template-resource-order', ], }, - // ProjectType has no derived scaffolder Template, so only the type - // entity itself is removed. - { kind: 'ProjectType', expectedRefs: ['projecttype:test-ns/order'] }, + { + kind: 'ProjectType', + expectedRefs: [ + 'projecttype:test-ns/order', + 'template:test-ns/template-project-order', + ], + }, { kind: 'Resource', expectedRefs: ['resource:test-ns/order'] }, { kind: 'Workflow', expectedRefs: ['workflow:test-ns/order'] }, ]; @@ -157,7 +163,10 @@ describe('EventDeltaApplier.handleEvent', () => { }, { kind: 'ClusterProjectType', - expectedRefs: ['clusterprojecttype:openchoreo-cluster/global'], + expectedRefs: [ + 'clusterprojecttype:openchoreo-cluster/global', + 'template:openchoreo-cluster/template-project-global', + ], }, { kind: 'ClusterTrait', @@ -198,6 +207,101 @@ describe('EventDeltaApplier.handleEvent', () => { }, ); + it('upserts the ProjectType entity and its generated project template on a create event', async () => { + mockGET.mockImplementation((path: string) => { + if (path.endsWith('/projecttypes/{ptName}')) { + return Promise.resolve( + okData({ + metadata: { + name: 'web-app', + namespace: 'test-ns', + creationTimestamp: '2026-06-01T10:00:00Z', + annotations: { + 'openchoreo.dev/display-name': 'Web Application', + }, + }, + spec: { + parameters: { + openAPIV3Schema: { + type: 'object', + properties: { replicas: { type: 'integer' } }, + }, + }, + resources: [], + }, + }), + ); + } + return Promise.resolve(notFound()); + }); + + const applier = newApplier(connection); + await applier.handleEvent('ProjectType', 'web-app', 'test-ns', 'created'); + + expect(applyMutation).toHaveBeenCalledTimes(1); + const call = applyMutation.mock.calls[0][0]; + expect(call.removed).toEqual([]); + const added = call.added.map((a: any) => a.entity); + expect( + added.find((e: any) => e.kind === 'ProjectType')?.metadata.name, + ).toBe('web-app'); + const tmpl = added.find( + (e: any) => + e.kind === 'Template' && e.metadata.name === 'template-project-web-app', + ); + expect(tmpl).toBeDefined(); + expect(tmpl.metadata.namespace).toBe('test-ns'); + expect(tmpl.spec.type).toBe('Project'); + // Schema from the type round-trips into the wizard's parameters field. + expect( + tmpl.spec.parameters[1].properties.parameters['ui:options'].ptdSchema + .properties.replicas.type, + ).toBe('integer'); + }); + + it('upserts the ClusterProjectType entity and its generated project template on a create event', async () => { + mockGET.mockImplementation((path: string) => { + if (path.endsWith('/clusterprojecttypes/{cptName}')) { + return Promise.resolve( + okData({ + metadata: { + name: 'standard', + creationTimestamp: '2026-06-01T10:00:00Z', + }, + spec: { + parameters: { + openAPIV3Schema: { type: 'object', properties: {} }, + }, + resources: [], + }, + }), + ); + } + return Promise.resolve(notFound()); + }); + + const applier = newApplier(connection); + await applier.handleEvent( + 'ClusterProjectType', + 'standard', + undefined, + 'updated', + ); + + const call = applyMutation.mock.calls[0][0]; + const added = call.added.map((a: any) => a.entity); + expect( + added.find((e: any) => e.kind === 'ClusterProjectType')?.metadata.name, + ).toBe('standard'); + const tmpl = added.find( + (e: any) => + e.kind === 'Template' && + e.metadata.name === 'template-project-standard', + ); + expect(tmpl).toBeDefined(); + expect(tmpl.metadata.namespace).toBe('openchoreo-cluster'); + }); + it('routes Namespace events to a Domain entity in the "default" namespace', async () => { const applier = newApplier(connection); @@ -240,6 +344,7 @@ describe('EventDeltaApplier.handleEvent', () => { getConnection: () => connection, ctdConverter: new CtdToTemplateConverter(logger), rtdConverter: new RtdToTemplateConverter(logger), + ptdConverter: new PtdToTemplateConverter(logger), }); await applier.handleEvent('NotARealKind', 'foo', 'ns', 'created'); @@ -271,6 +376,7 @@ describe('EventDeltaApplier.handleEvent', () => { getConnection: () => undefined, ctdConverter: new CtdToTemplateConverter(mkLogger()), rtdConverter: new RtdToTemplateConverter(mkLogger()), + ptdConverter: new PtdToTemplateConverter(mkLogger()), }); await expect( @@ -495,6 +601,7 @@ describe('EventDeltaApplier.handleEvent', () => { getConnection: () => connection, ctdConverter: new CtdToTemplateConverter(logger), rtdConverter: new RtdToTemplateConverter(logger), + ptdConverter: new PtdToTemplateConverter(logger), }); await applier.handleEvent('Workload', 'orphan', 'test-ns', 'created'); @@ -524,6 +631,7 @@ describe('EventDeltaApplier.handleEvent', () => { getConnection: () => connection, ctdConverter: new CtdToTemplateConverter(logger), rtdConverter: new RtdToTemplateConverter(logger), + ptdConverter: new PtdToTemplateConverter(logger), }); await applier.handleEvent('Workload', 'gone', 'test-ns', 'created'); @@ -570,6 +678,7 @@ describe('EventDeltaApplier.handleEvent', () => { getConnection: () => connection, ctdConverter: new CtdToTemplateConverter(mkLogger()), rtdConverter: new RtdToTemplateConverter(mkLogger()), + ptdConverter: new PtdToTemplateConverter(mkLogger()), catalogService, auth, }); @@ -651,6 +760,7 @@ describe('EventDeltaApplier.handleEvent', () => { getConnection: () => connection, ctdConverter: new CtdToTemplateConverter(logger), rtdConverter: new RtdToTemplateConverter(logger), + ptdConverter: new PtdToTemplateConverter(logger), catalogService, auth: makeAuth(), }); @@ -709,6 +819,7 @@ describe('EventDeltaApplier.handleEvent', () => { getConnection: () => connection, ctdConverter: new CtdToTemplateConverter(logger), rtdConverter: new RtdToTemplateConverter(logger), + ptdConverter: new PtdToTemplateConverter(logger), // No catalogService / auth — production always wires them, but // tests/legacy callers may not. }); diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts index f9f4b45cd..8f51b28c3 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts @@ -56,6 +56,10 @@ import { } from '@openchoreo/openchoreo-client-node'; import { CtdToTemplateConverter } from '../converters/CtdToTemplateConverter'; import { RtdToTemplateConverter } from '../converters/RtdToTemplateConverter'; +import { + PtdToTemplateConverter, + ProjectTypeCRD, +} from '../converters/PtdToTemplateConverter'; type NewProject = OpenChoreoComponents['schemas']['Project']; type NewComponent = OpenChoreoComponents['schemas']['Component']; @@ -114,6 +118,7 @@ export class EventDeltaApplier { private readonly getConnection: () => EntityProviderConnection | undefined; private readonly ctdConverter: CtdToTemplateConverter; private readonly rtdConverter: RtdToTemplateConverter; + private readonly ptdConverter: PtdToTemplateConverter; private readonly catalogService?: CatalogService; private readonly auth?: AuthService; @@ -126,6 +131,7 @@ export class EventDeltaApplier { getConnection: () => EntityProviderConnection | undefined; ctdConverter: CtdToTemplateConverter; rtdConverter: RtdToTemplateConverter; + ptdConverter: PtdToTemplateConverter; /** * Optional catalog read-side. Used by the workload-deletion handler * to find entities annotated with the deleted workload's name and @@ -146,6 +152,7 @@ export class EventDeltaApplier { this.translatorContext = opts.translatorContext; this.ctdConverter = opts.ctdConverter; this.rtdConverter = opts.rtdConverter; + this.ptdConverter = opts.ptdConverter; this.getConnection = opts.getConnection; this.catalogService = opts.catalogService; this.auth = opts.auth; @@ -1077,6 +1084,88 @@ export class EventDeltaApplier { } } + /** + * Produces the derived Project-creation Template entity for a ProjectType + * via `PtdToTemplateConverter`. Unlike the Resource family, the + * (Cluster)ProjectType already carries its parameters schema inline, so the + * schema is read straight off the fetched CR (no extra `/schema` fetch). + */ + private buildProjectTypeTemplateEntity( + ns: string, + pt: NewProjectType, + ): Entity | undefined { + const ptName = getName(pt); + if (!ptName) return undefined; + try { + const templateEntity = this.ptdConverter.convertPtdToTemplateEntity( + this.toProjectTypeCRD(pt, ptName), + ns, + ); + this.stampManagedByLocation(templateEntity); + return templateEntity; + } catch (error) { + this.logger.warn( + `Failed to build Template entity for ProjectType ${ns}/${ptName}: ${error}`, + ); + return undefined; + } + } + + /** + * Same as `buildProjectTypeTemplateEntity` but for a cluster-scoped + * ProjectType. Template entity lives in `openchoreo-cluster`. + */ + private buildClusterProjectTypeTemplateEntity( + cpt: NewClusterProjectType, + ): Entity | undefined { + const cptName = getName(cpt); + if (!cptName) return undefined; + try { + const templateEntity = + this.ptdConverter.convertClusterPtdToTemplateEntity( + this.toProjectTypeCRD(cpt, cptName), + ); + this.stampManagedByLocation(templateEntity); + return templateEntity; + } catch (error) { + this.logger.warn( + `Failed to build Template entity for ClusterProjectType ${cptName}: ${error}`, + ); + return undefined; + } + } + + /** Map a fetched (Cluster)ProjectType to the converter's `ProjectTypeCRD` shape. */ + private toProjectTypeCRD( + pt: NewProjectType | NewClusterProjectType, + name: string, + ): ProjectTypeCRD { + return { + metadata: { + name, + displayName: getDisplayName(pt), + description: getDescription(pt), + createdAt: getCreatedAt(pt) || '', + }, + spec: { + parameters: pt.spec?.parameters?.openAPIV3Schema + ? { openAPIV3Schema: pt.spec.parameters.openAPIV3Schema as any } + : undefined, + }, + }; + } + + /** Stamp the provider's managed-by-location annotations on a generated entity. */ + private stampManagedByLocation(entity: Entity): void { + if (!entity.metadata.annotations) { + entity.metadata.annotations = {}; + } + entity.metadata.annotations['backstage.io/managed-by-location'] = + this.locationKey(); + entity.metadata.annotations['backstage.io/managed-by-origin-location'] = + this.locationKey(); + } + private async refreshComponentType(ns: string, name: string): Promise { const client = await this.createApiClient(); const ct = await this.fetchComponentType(client, ns, name); @@ -1149,14 +1238,23 @@ export class EventDeltaApplier { const client = await this.createApiClient(); const pt = await this.fetchProjectType(client, ns, name); if (!pt) { + // Both the ProjectType entity and its derived Template entity need to + // disappear. The Template name is `template-project-`. await this.removeEntityRefs([ this.buildEntityRef('projecttype', ns, name), + this.buildEntityRef('template', ns, `template-project-${name}`), ]); return; } - await this.upsertEntities([ - translateNewProjectTypeToEntity(pt, ns, this.translatorContext) as Entity, - ]); + const ptEntity = translateNewProjectTypeToEntity( + pt, + ns, + this.translatorContext, + ) as Entity; + const templateEntity = this.buildProjectTypeTemplateEntity(ns, pt); + await this.upsertEntities( + templateEntity ? [ptEntity, templateEntity] : [ptEntity], + ); } private async refreshResource(ns: string, name: string): Promise { @@ -1270,15 +1368,22 @@ export class EventDeltaApplier { if (!cpt) { await this.removeEntityRefs([ this.buildEntityRef('clusterprojecttype', 'openchoreo-cluster', name), + this.buildEntityRef( + 'template', + 'openchoreo-cluster', + `template-project-${name}`, + ), ]); return; } - await this.upsertEntities([ - translateNewClusterProjectTypeToEntity( - cpt, - this.translatorContext, - ) as Entity, - ]); + const cptEntity = translateNewClusterProjectTypeToEntity( + cpt, + this.translatorContext, + ) as Entity; + const templateEntity = this.buildClusterProjectTypeTemplateEntity(cpt); + await this.upsertEntities( + templateEntity ? [cptEntity, templateEntity] : [cptEntity], + ); } private async refreshClusterTrait(name: string): Promise { diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts index 548100e12..961188f51 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.test.ts @@ -220,6 +220,35 @@ const k8sResourceType = { status: { conditions: [readyCondition] }, }; +const k8sClusterProjectType = { + metadata: k8sMeta('web-application'), + spec: { + parameters: { + openAPIV3Schema: { + type: 'object', + properties: { replicas: { type: 'integer' } }, + required: ['replicas'], + }, + }, + resources: [], + }, + status: { conditions: [readyCondition] }, +}; + +const k8sProjectType = { + metadata: k8sMeta('internal-service'), + spec: { + parameters: { + openAPIV3Schema: { + type: 'object', + properties: { port: { type: 'integer' } }, + }, + }, + resources: [], + }, + status: { conditions: [readyCondition] }, +}; + const k8sResource = { metadata: k8sMeta('analytics-db'), spec: { @@ -435,6 +464,12 @@ describe('OpenChoreoEntityProvider', () => { '/api/v1/clusterresourcetypes': okData({ items: [k8sClusterResourceType], }), + '/api/v1/namespaces/{namespaceName}/projecttypes': okData({ + items: [k8sProjectType], + }), + '/api/v1/clusterprojecttypes': okData({ + items: [k8sClusterProjectType], + }), '/api/v1/namespaces': okData({ items: [k8sNamespace] }), }); }); @@ -477,6 +512,41 @@ describe('OpenChoreoEntityProvider', () => { ); }); + it('creates ProjectType / ClusterProjectType entities and their per-type project templates', async () => { + const entities = await runProvider(); + + const pts = findEntities(entities, 'ProjectType'); + expect(pts).toHaveLength(1); + expect(pts[0].metadata.name).toBe('internal-service'); + expect(pts[0].metadata.namespace).toBe('test-ns'); + + const cpts = findEntities(entities, 'ClusterProjectType'); + expect(cpts).toHaveLength(1); + expect(cpts[0].metadata.name).toBe('web-application'); + expect(cpts[0].metadata.namespace).toBe('openchoreo-cluster'); + + // PtdToTemplateConverter emits one scaffolder Template per type. + const templates = findEntities(entities, 'Template'); + const nsTemplate = templates.find( + t => t.metadata.name === 'template-project-internal-service', + ); + expect(nsTemplate).toBeDefined(); + expect(nsTemplate!.metadata.namespace).toBe('test-ns'); + expect((nsTemplate!.spec as any).type).toBe('Project'); + + const clusterTemplate = templates.find( + t => t.metadata.name === 'template-project-web-application', + ); + expect(clusterTemplate).toBeDefined(); + expect(clusterTemplate!.metadata.namespace).toBe('openchoreo-cluster'); + // The type's parameter schema is carried into the wizard field options. + expect( + (clusterTemplate!.spec as any).parameters[1].properties.parameters[ + 'ui:options' + ].ptdSchema.properties.replicas.type, + ).toBe('integer'); + }); + it('creates ResourceType entity in the owning namespace with a domain ref', async () => { const entities = await runProvider(); const rts = findEntities(entities, 'ResourceType'); diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts index 57cfbfaec..c665fa4f4 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts @@ -24,6 +24,10 @@ import { ComponentTypeUtils } from '@openchoreo/backstage-plugin-common'; import { DeploymentPipelineEntityV1alpha1 } from '../kinds'; import { CtdToTemplateConverter } from '../converters/CtdToTemplateConverter'; import { RtdToTemplateConverter } from '../converters/RtdToTemplateConverter'; +import { + PtdToTemplateConverter, + ProjectTypeCRD, +} from '../converters/PtdToTemplateConverter'; import { ComponentWorkloadData } from '../utils/types'; import { buildComponentDependsOnRefs, @@ -107,6 +111,7 @@ export class OpenChoreoEntityProvider implements EntityProvider { private readonly defaultOwner: string; private readonly ctdConverter: CtdToTemplateConverter; private readonly rtdConverter: RtdToTemplateConverter; + private readonly ptdConverter: PtdToTemplateConverter; private readonly componentTypeUtils: ComponentTypeUtils; private readonly tokenService?: OpenChoreoTokenService; private readonly events?: EventsService; @@ -144,6 +149,11 @@ export class OpenChoreoEntityProvider implements EntityProvider { this.rtdConverter = new RtdToTemplateConverter({ defaultOwner: this.defaultOwner, }); + // Initialize PTD to Template converter — generates per-type Project + // wizards from (Cluster)ProjectType entities. + this.ptdConverter = new PtdToTemplateConverter({ + defaultOwner: this.defaultOwner, + }); // Initialize component type utilities from config this.componentTypeUtils = ComponentTypeUtils.fromConfig(config); @@ -162,6 +172,7 @@ export class OpenChoreoEntityProvider implements EntityProvider { getConnection: () => this.connection, ctdConverter: this.ctdConverter, rtdConverter: this.rtdConverter, + ptdConverter: this.ptdConverter, catalogService, auth, }); @@ -171,6 +182,42 @@ export class OpenChoreoEntityProvider implements EntityProvider { return 'OpenChoreoEntityProvider'; } + /** + * Map a fetched (Cluster)ProjectType list item to the `ProjectTypeCRD` shape + * consumed by `PtdToTemplateConverter`. The list endpoint returns the full + * `spec.parameters.openAPIV3Schema` inline, so no extra fetch is required. + */ + private toProjectTypeCRD( + pt: NewProjectType | NewClusterProjectType, + ): ProjectTypeCRD { + return { + metadata: { + name: getName(pt)!, + displayName: getDisplayName(pt), + description: getDescription(pt), + createdAt: getCreatedAt(pt) || '', + }, + spec: { + parameters: pt.spec?.parameters?.openAPIV3Schema + ? { openAPIV3Schema: pt.spec.parameters.openAPIV3Schema as any } + : undefined, + }, + }; + } + + /** Stamp the provider's managed-by-location annotations on a generated entity. */ + private stampManagedByLocation(entity: Entity): void { + if (!entity.metadata.annotations) { + entity.metadata.annotations = {}; + } + entity.metadata.annotations[ + 'backstage.io/managed-by-location' + ] = `provider:${this.getProviderName()}`; + entity.metadata.annotations[ + 'backstage.io/managed-by-origin-location' + ] = `provider:${this.getProviderName()}`; + } + async connect(connection: EntityProviderConnection): Promise { this.connection = connection; @@ -1107,6 +1154,34 @@ export class OpenChoreoEntityProvider implements EntityProvider { }) .filter((e): e is Entity => e !== null); allEntities.push(...ptEntities); + + // Generate per-type Project-creation Template entities. The + // (Cluster)ProjectType list returns the full parameters schema + // inline, so no extra /schema fetch is needed. + const ptTemplateEntities: Entity[] = projectTypes + .map(pt => { + try { + const templateEntity = + this.ptdConverter.convertPtdToTemplateEntity( + this.toProjectTypeCRD(pt), + nsName, + ); + this.stampManagedByLocation(templateEntity); + return templateEntity; + } catch (error) { + this.logger.warn( + `Failed to convert ProjectType ${getName( + pt, + )} to template: ${error}`, + ); + return null; + } + }) + .filter((entity): entity is Entity => entity !== null); + allEntities.push(...ptTemplateEntities); + this.logger.debug( + `Generated ${ptTemplateEntities.length} template entities from ProjectTypes in namespace: ${nsName}`, + ); } catch (error) { this.logger.warn( `Failed to fetch project types for namespace ${nsName}: ${error}`, @@ -1477,6 +1552,31 @@ export class OpenChoreoEntityProvider implements EntityProvider { }) .filter((e): e is Entity => e !== null); allEntities.push(...cptEntities); + + // Generate per-type Project-creation Template entities (cluster scope). + const cptTemplateEntities: Entity[] = clusterProjectTypes + .map(cpt => { + try { + const templateEntity = + this.ptdConverter.convertClusterPtdToTemplateEntity( + this.toProjectTypeCRD(cpt), + ); + this.stampManagedByLocation(templateEntity); + return templateEntity; + } catch (error) { + this.logger.warn( + `Failed to convert ClusterProjectType ${getName( + cpt, + )} to template: ${error}`, + ); + return null; + } + }) + .filter((entity): entity is Entity => entity !== null); + allEntities.push(...cptTemplateEntities); + this.logger.info( + `Successfully generated ${cptTemplateEntities.length} template entities from ClusterProjectTypes`, + ); } catch (error) { this.logger.warn(`Failed to fetch cluster project types: ${error}`); } diff --git a/plugins/openchoreo-common/src/constants.ts b/plugins/openchoreo-common/src/constants.ts index f47ab6705..b2a964607 100644 --- a/plugins/openchoreo-common/src/constants.ts +++ b/plugins/openchoreo-common/src/constants.ts @@ -39,6 +39,14 @@ export const CHOREO_ANNOTATIONS = { RTD_DISPLAY_NAME: 'openchoreo.io/rtd-display-name', RTD_GENERATED: 'openchoreo.io/rtd-generated', RTD_KIND: 'openchoreo.io/rtd-kind', + // (Cluster)ProjectType Definition (PTD) annotations + // Umbrella prefix `PTD` mirrors `RTD`/`CTD`: marks scaffolder Templates + // generated per (Cluster)ProjectType so the Project-creation browse view + // (`?view=projects`) can list them. The kind is carried in PTD_KIND. + PTD_NAME: 'openchoreo.io/ptd-name', + PTD_DISPLAY_NAME: 'openchoreo.io/ptd-display-name', + PTD_GENERATED: 'openchoreo.io/ptd-generated', + PTD_KIND: 'openchoreo.io/ptd-kind', // Deletion tracking DELETION_TIMESTAMP: 'openchoreo.io/deletion-timestamp', // Agent connection status diff --git a/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx b/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx index bea756ef9..64359af8f 100644 --- a/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx +++ b/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx @@ -108,7 +108,7 @@ export const NamespaceProjectsCard = () => { isFreeAction: true, onClick: () => navigate( - `/create/templates/default/create-openchoreo-project?namespace=${entity.metadata.name}`, + `/create?view=projects&namespace=${entity.metadata.name}`, ), }, ]} diff --git a/plugins/scaffolder-backend-module-openchoreo/src/actions/project.test.ts b/plugins/scaffolder-backend-module-openchoreo/src/actions/project.test.ts index f20d3d517..2b21f0b7f 100644 --- a/plugins/scaffolder-backend-module-openchoreo/src/actions/project.test.ts +++ b/plugins/scaffolder-backend-module-openchoreo/src/actions/project.test.ts @@ -1,3 +1,4 @@ +import { translateProjectToEntity } from '@openchoreo/backstage-plugin-catalog-backend-module'; import { createProjectAction } from './project'; const mockPOST = jest.fn(); @@ -111,6 +112,93 @@ describe('createProjectAction', () => { expect(ctx.output).toHaveBeenCalledWith('namespaceName', 'extracted-ns'); }); + it('includes type and parameters in the request body when provided', async () => { + mockPOST.mockResolvedValueOnce(successResponse()); + const action = createProjectAction( + buildConfig(), + mockImmediateCatalog as any, + ); + const ctx = buildCtx({ + input: { + typeKind: 'ClusterProjectType', + typeName: 'web-app', + parameters: { replicas: 3 }, + }, + }); + await action.handler(ctx as any); + + const body = mockPOST.mock.calls[0][1].body; + expect(body.spec.type).toEqual({ + kind: 'ClusterProjectType', + name: 'web-app', + }); + expect(body.spec.parameters).toEqual({ replicas: 3 }); + expect(body.spec.deploymentPipelineRef).toEqual({ + kind: 'DeploymentPipeline', + name: 'default-pipeline', + }); + }); + + it('omits type and parameters when not provided (legacy path)', async () => { + mockPOST.mockResolvedValueOnce(successResponse()); + const action = createProjectAction( + buildConfig(), + mockImmediateCatalog as any, + ); + await action.handler(buildCtx() as any); + + const body = mockPOST.mock.calls[0][1].body; + expect(body.spec.type).toBeUndefined(); + expect(body.spec.parameters).toBeUndefined(); + expect(body.spec.deploymentPipelineRef.name).toBe('default-pipeline'); + }); + + it('defaults type kind to ProjectType when only typeName is given', async () => { + mockPOST.mockResolvedValueOnce(successResponse()); + const action = createProjectAction( + buildConfig(), + mockImmediateCatalog as any, + ); + await action.handler(buildCtx({ input: { typeName: 'standard' } }) as any); + expect(mockPOST.mock.calls[0][1].body.spec.type).toEqual({ + kind: 'ProjectType', + name: 'standard', + }); + }); + + it('omits an empty parameters object from the body', async () => { + mockPOST.mockResolvedValueOnce(successResponse()); + const action = createProjectAction( + buildConfig(), + mockImmediateCatalog as any, + ); + await action.handler( + buildCtx({ input: { typeName: 'standard', parameters: {} } }) as any, + ); + expect(mockPOST.mock.calls[0][1].body.spec.parameters).toBeUndefined(); + }); + + it('passes project type into the catalog translation', async () => { + mockPOST.mockResolvedValueOnce(successResponse()); + const action = createProjectAction( + buildConfig(), + mockImmediateCatalog as any, + ); + await action.handler( + buildCtx({ + input: { typeKind: 'ProjectType', typeName: 'web-app' }, + }) as any, + ); + expect(translateProjectToEntity).toHaveBeenCalledWith( + expect.objectContaining({ + projectTypeName: 'web-app', + projectTypeKind: 'ProjectType', + }), + expect.anything(), + expect.anything(), + ); + }); + it('throws on API error', async () => { mockPOST.mockResolvedValueOnce({ data: undefined, diff --git a/plugins/scaffolder-backend-module-openchoreo/src/actions/project.ts b/plugins/scaffolder-backend-module-openchoreo/src/actions/project.ts index f4a1dea7d..eb9fe6648 100644 --- a/plugins/scaffolder-backend-module-openchoreo/src/actions/project.ts +++ b/plugins/scaffolder-backend-module-openchoreo/src/actions/project.ts @@ -36,6 +36,27 @@ export const createProjectAction = ( z.string({ description: 'The deployment pipeline for the project', }), + typeKind: z => + z + .enum(['ProjectType', 'ClusterProjectType'], { + description: + 'Whether the selected project type is namespace-scoped (ProjectType) or cluster-scoped (ClusterProjectType)', + }) + .optional(), + typeName: z => + z + .string({ + description: + 'Name of the (Cluster)ProjectType template the Project instantiates', + }) + .optional(), + parameters: z => + z + .record(z.unknown(), { + description: + "Parameter values bound to the selected project type's schema", + }) + .optional(), }, output: { projectName: z => @@ -98,6 +119,44 @@ export const createProjectAction = ( logger: ctx.logger, }); + const { typeKind, typeName, parameters } = ctx.input; + const hasParameters = parameters && Object.keys(parameters).length > 0; + + // Build the Project spec. `type` (ProjectTypeRef) and `parameters` are + // populated when the Project is created from a per-ProjectType template; + // when omitted the OpenChoreo API defaults `type` to the cluster-scoped + // `default` ClusterProjectType, preserving back-compat with callers that + // don't pass a type (e.g. the legacy create path). + const spec: Record = { + deploymentPipelineRef: { + kind: 'DeploymentPipeline' as const, + name: ctx.input.deploymentPipeline, + }, + ...(typeName && { + type: { kind: typeKind ?? 'ProjectType', name: typeName }, + }), + ...(hasParameters && { parameters }), + }; + + const apiBody: Record = { + metadata: { + name: ctx.input.projectName, + annotations: { + ...(ctx.input.displayName + ? { + 'openchoreo.dev/display-name': ctx.input.displayName, + } + : {}), + ...(ctx.input.description + ? { + 'openchoreo.dev/description': ctx.input.description, + } + : {}), + }, + }, + spec, + }; + try { const { data, error, response } = await client.POST( '/api/v1/namespaces/{namespaceName}/projects', @@ -105,29 +164,7 @@ export const createProjectAction = ( params: { path: { namespaceName }, }, - body: { - metadata: { - name: ctx.input.projectName, - annotations: { - ...(ctx.input.displayName - ? { - 'openchoreo.dev/display-name': ctx.input.displayName, - } - : {}), - ...(ctx.input.description - ? { - 'openchoreo.dev/description': ctx.input.description, - } - : {}), - }, - }, - spec: { - deploymentPipelineRef: { - kind: 'DeploymentPipeline' as const, - name: ctx.input.deploymentPipeline, - }, - }, - }, + body: apiBody as any, }, ); @@ -161,6 +198,8 @@ export const createProjectAction = ( annotations['openchoreo.dev/description'], namespaceName: namespaceName, uid: data?.metadata?.uid, + ...(typeName && { projectTypeName: typeName }), + ...(typeKind && { projectTypeKind: typeKind }), }, namespaceName, { diff --git a/templates/create-openchoreo-project/template.yaml b/templates/create-openchoreo-project/template.yaml deleted file mode 100644 index e0795f7b7..000000000 --- a/templates/create-openchoreo-project/template.yaml +++ /dev/null @@ -1,70 +0,0 @@ -apiVersion: scaffolder.backstage.io/v1beta3 -kind: Template -metadata: - name: create-openchoreo-project - title: Project - description: Group related components and services within an isolated boundary. -spec: - owner: openchoreo-users - type: System (Project) - # Enable user token injection for user-based authorization at OpenChoreo API - EXPERIMENTAL_formDecorators: - - id: openchoreo:inject-user-token - parameters: - - title: OpenChoreo Project Details - required: - - namespace_name - - project_name - - deployment_pipeline - properties: - namespace_name: - title: Namespace Name - type: string - description: Namespace where the project will be created - ui:field: NamespaceEntityPicker - project_name: - title: Project Name - type: string - description: Unique name for your project (must be a valid Kubernetes name) - ui:field: ResourceNamePicker - ui:options: - catalogKind: System - resourceLabel: Project - namespaceField: namespace_name - displayName: - title: Display Name - type: string - description: Display name of the project - description: - title: Description - type: string - description: Help others understand what this project is for. - deployment_pipeline: - title: Deployment Pipeline - type: string - description: Deployment pipeline to associate with this project - ui:field: DeploymentPipelinePicker - # owner: - # title: Owner - # type: string - # description: Owner of the project - # ui:field: EntityPicker - # ui:options: - # catalogFilter: - # - kind: Group - # spec.type: team - steps: - - id: create-openchoreo-project - name: Create OpenChoreo Project - action: openchoreo:project:create - input: - namespaceName: ${{ parameters.namespace_name }} - projectName: ${{ parameters.project_name }} - displayName: ${{ parameters.displayName }} - description: ${{ parameters.description }} - deploymentPipeline: ${{ parameters.deployment_pipeline }} - output: - links: - - title: View Project - icon: catalog - entityRef: ${{ steps['create-openchoreo-project'].output.entityRef }}