- Review your devfile configuration and download the YAML file.
+ Review the devfile and download it. Errors must be fixed before downloading, warnings are advisory.
+
+ {errors.length === 0 && warnings.length === 0 && (
+
+ The devfile matches the devfile 2.3.0 schema and the devfile validation rules.
+
+ )}
+
+ {errors.length > 0 && (
+
+
+ {errors.map((error) => (
+ {error}
+ ))}
+
+
+ )}
+
+ {warnings.length > 0 && (
+
+
+ {warnings.map((warning) => (
+ {warning}
+ ))}
+
+
+ )}
+
+
+
- {renderSummarySection(
- 'Basic Information',
- 0,
- metadata.name !== '',
-
- {metadata.name &&
Name: {metadata.name}
}
- {metadata.version &&
Version: {metadata.version}
}
- {metadata.displayName &&
Display Name: {metadata.displayName}
}
- {metadata.language &&
Language: {metadata.language}
}
-
- )}
-
- {renderSummarySection(
- 'Projects',
- 1,
- projects.length > 0,
-
- {projects.length > 0 ? (
-
{projects.length} project(s) configured
- ) : (
-
No projects added
- )}
-
- )}
-
- {renderSummarySection(
- 'Components',
- 2,
- components.length > 0,
-
- {components.length > 0 ? (
-
{components.length} component(s) configured
- ) : (
-
No components added
- )}
-
- )}
+
+ {metadata.name && (
+
+ Name: {metadata.name}
+
+ )}
+ {metadata.generateName && (
+
+ Generate name: {metadata.generateName}
+
+ )}
+ {metadata.version && (
+
+ Version: {metadata.version}
+
+ )}
+ {metadata.language && (
+
+ Language: {metadata.language}
+
+ )}
+ {metadata.projectType && (
+
+ Project type: {metadata.projectType}
+
+ )}
+ {!metadata.name && !metadata.generateName && No name set
}
+
+
+
+ {devfile.parent ? (
+
+ Inherits from: {' '}
+ {devfile.parent.uri || devfile.parent.id || devfile.parent.kubernetes?.name}
+
+ ) : (
+ No parent devfile
+ )}
+
+
+
0} onEdit={handleEdit}>
+ {projectCount === 0 ? (
+ No projects added
+ ) : (
+ <>
+ {devfile.projects && {devfile.projects.length} project(s)
}
+ {devfile.starterProjects && {devfile.starterProjects.length} starter project(s)
}
+ {devfile.dependentProjects && {devfile.dependentProjects.length} dependent project(s)
}
+ >
+ )}
+
- {renderSummarySection(
- 'Commands',
- 3,
- commands.length > 0,
-
- {commands.length > 0 ? (
-
{commands.length} command(s) configured
- ) : (
-
No commands added
- )}
-
- )}
-
- {renderSummarySection(
- 'Events',
- 4,
- Object.values(events).some(arr => arr.length > 0),
-
- {events.preStart?.length > 0 &&
Pre Start: {events.preStart.length} command(s)
}
- {events.postStart?.length > 0 &&
Post Start: {events.postStart.length} command(s)
}
- {events.preStop?.length > 0 &&
Pre Stop: {events.preStop.length} command(s)
}
- {events.postStop?.length > 0 &&
Post Stop: {events.postStop.length} command(s)
}
- {!Object.values(events).some(arr => arr.length > 0) && (
-
No events configured
- )}
-
- )}
-
- {renderSummarySection(
- 'Variables',
- 5,
- Object.keys(variables).length > 0,
-
- {Object.keys(variables).length > 0 ? (
-
{Object.keys(variables).length} variable(s) defined
- ) : (
-
No variables defined
- )}
-
- )}
+
+ {devfile.components ? (
+ {countBy(data.components, getComponentType)}
+ ) : (
+ No components added
+ )}
+
+
+
+ {devfile.commands ? (
+ {countBy(data.commands, getCommandType)}
+ ) : (
+ No commands added
+ )}
+
+
+
0} onEdit={handleEdit}>
+ {eventCount === 0 ? (
+ No events configured
+ ) : (
+ Object.entries(events).map(([eventType, commandIds]) => (
+
+ {eventType}: {commandIds.join(', ')}
+
+ ))
+ )}
+
+
+
+ {devfile.variables ? (
+ {Object.keys(devfile.variables).join(', ')}
+ ) : (
+ No variables defined
+ )}
+
-
+
Reset Wizard
- 0}
- className="px-6"
- >
+ 0} className="px-6">
Download devfile.yaml
diff --git a/src/components/steps/VariablesStep.jsx b/src/components/steps/VariablesStep.jsx
index 694a6e2..6261ec4 100644
--- a/src/components/steps/VariablesStep.jsx
+++ b/src/components/steps/VariablesStep.jsx
@@ -1,118 +1,42 @@
import { useWizard } from '../../hooks/useWizardState'
-import FormInput from '../forms/FormInput'
-import Button from '../common/Button'
-import WizardNavigation from '../wizard/WizardNavigation'
+import MapFieldManager from '../forms/MapFieldManager'
import Alert from '../common/Alert'
+import StepIssues from '../wizard/StepIssues'
+import WizardNavigation from '../wizard/WizardNavigation'
+
+const ISSUE_PREFIXES = ['Variables']
export default function VariablesStep() {
const { state, dispatch, actions } = useWizard()
- const variables = state.devfileData.variables || {}
-
- const variableEntries = Object.entries(variables)
-
- const handleAddVariable = () => {
- const newVars = { ...variables, [`VAR_${Object.keys(variables).length + 1}`]: '' }
- dispatch({
- type: actions.UPDATE_VARIABLES,
- payload: newVars
- })
- }
-
- const handleRemoveVariable = (key) => {
- const newVars = { ...variables }
- delete newVars[key]
- dispatch({
- type: actions.UPDATE_VARIABLES,
- payload: newVars
- })
- }
-
- const handleUpdateKey = (oldKey, newKey) => {
- if (oldKey === newKey) return
-
- const newVars = {}
- for (const [k, v] of Object.entries(variables)) {
- if (k === oldKey) {
- newVars[newKey] = v
- } else {
- newVars[k] = v
- }
- }
-
- dispatch({
- type: actions.UPDATE_VARIABLES,
- payload: newVars
- })
- }
-
- const handleUpdateValue = (key, value) => {
- dispatch({
- type: actions.UPDATE_VARIABLES,
- payload: {
- ...variables,
- [key]: value
- }
- })
- }
+ const variables = state.devfileData.variables || []
return (
Variables
- Define variables that can be referenced throughout your devfile using the syntax: {'{{variable-name}}'}
+ Values referenced elsewhere in the devfile with the{' '}
+ {'{{variable-name}}'} syntax.
-
- Variables are replaced in string fields throughout the devfile. Use them to avoid repetition and make your devfile more maintainable.
+
+ Variables are substituted in the string fields of components, commands, projects and events. Keys may only
+ contain alphanumeric characters, dashes and underscores.
-
- {variableEntries.length === 0 ? (
-
-
No variables defined yet.
-
- ) : (
- variableEntries.map(([key, value], index) => (
-
-
- handleUpdateKey(key, e.target.value)}
- placeholder="VARIABLE_NAME"
- />
-
-
- handleUpdateValue(key, e.target.value)}
- placeholder="variable value"
- />
-
-
- handleRemoveVariable(key)}
- className="text-xs px-2 py-1"
- >
- Remove
-
-
-
- ))
- )}
-
-
- Add Variable
-
-
+
+
+ dispatch({ type: actions.SET_SECTION, payload: { section: 'variables', value } })}
+ keyLabel="Variable name"
+ valueLabel="Variable value"
+ keyPlaceholder="VERSION"
+ valuePlaceholder="1.0.0"
+ addButtonText="Add Variable"
+ emptyMessage="No variables defined yet."
+ />
diff --git a/src/components/wizard/StepIssues.jsx b/src/components/wizard/StepIssues.jsx
new file mode 100644
index 0000000..6eb3497
--- /dev/null
+++ b/src/components/wizard/StepIssues.jsx
@@ -0,0 +1,54 @@
+import { useMemo } from 'react'
+import { useWizard } from '../../hooks/useWizardState'
+import { validateDevfile } from '../../utils/devfileGenerator'
+import Alert from '../common/Alert'
+
+/** Keeps the messages produced by `validateDevfile` that belong to one step. */
+export function filterIssues(messages, prefixes) {
+ return messages.filter((message) => prefixes.some((prefix) => message.startsWith(prefix)))
+}
+
+/**
+ * Live validation feedback for a single wizard step.
+ *
+ * The whole devfile is validated on every render, and each step only shows the
+ * messages it is responsible for, so the same rules back the step hints and the
+ * final review.
+ */
+export default function StepIssues({ prefixes, showWarnings = true }) {
+ const { state } = useWizard()
+
+ const { errors, warnings } = useMemo(() => {
+ const result = validateDevfile(state.devfileData)
+ return {
+ errors: filterIssues(result.errors, prefixes),
+ warnings: showWarnings ? filterIssues(result.warnings, prefixes) : []
+ }
+ }, [state.devfileData, prefixes, showWarnings])
+
+ if (errors.length === 0 && warnings.length === 0) return null
+
+ return (
+
+ {errors.length > 0 && (
+
+
+ {errors.map((error) => (
+ {error}
+ ))}
+
+
+ )}
+
+ {warnings.length > 0 && (
+
+
+ {warnings.map((warning) => (
+ {warning}
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/wizard/WizardContainer.jsx b/src/components/wizard/WizardContainer.jsx
index 07b6dfe..5df4bad 100644
--- a/src/components/wizard/WizardContainer.jsx
+++ b/src/components/wizard/WizardContainer.jsx
@@ -1,9 +1,10 @@
import { useWizard } from '../../hooks/useWizardState'
+import { WIZARD_STEPS } from '../../utils/constants'
import WizardProgressBar from './WizardProgressBar'
import Card from '../common/Card'
-// Import step components (will be created next)
import MetadataStep from '../steps/MetadataStep'
+import ParentStep from '../steps/ParentStep'
import ProjectsStep from '../steps/ProjectsStep'
import ComponentsStep from '../steps/ComponentsStep'
import CommandsStep from '../steps/CommandsStep'
@@ -11,35 +12,29 @@ import EventsStep from '../steps/EventsStep'
import VariablesStep from '../steps/VariablesStep'
import ReviewStep from '../steps/ReviewStep'
+/** Keyed by the step ids declared in `WIZARD_STEPS`. */
+const STEP_COMPONENTS = {
+ metadata: MetadataStep,
+ parent: ParentStep,
+ projects: ProjectsStep,
+ components: ComponentsStep,
+ commands: CommandsStep,
+ events: EventsStep,
+ variables: VariablesStep,
+ review: ReviewStep
+}
+
export default function WizardContainer() {
const { state } = useWizard()
- const renderStep = () => {
- switch (state.currentStep) {
- case 0:
- return
- case 1:
- return
- case 2:
- return
- case 3:
- return
- case 4:
- return
- case 5:
- return
- case 6:
- return
- default:
- return
- }
- }
+ const step = WIZARD_STEPS[state.currentStep] || WIZARD_STEPS[0]
+ const StepComponent = STEP_COMPONENTS[step.id]
return (
- {renderStep()}
+
)
diff --git a/src/hooks/useWizardState.jsx b/src/hooks/useWizardState.jsx
index 2a9a225..20c413b 100644
--- a/src/hooks/useWizardState.jsx
+++ b/src/hooks/useWizardState.jsx
@@ -1,59 +1,34 @@
-import { createContext, useContext, useReducer } from 'react'
+import { createContext, useContext, useMemo, useReducer } from 'react'
+import { WIZARD_STEPS } from '../utils/constants'
+import { createInitialDevfileData } from '../utils/factories'
const WizardContext = createContext()
+const LAST_STEP = WIZARD_STEPS.length - 1
+
const initialState = {
currentStep: 0,
completedSteps: [],
- devfileData: {
- schemaVersion: '2.3.0',
- metadata: {
- name: '',
- version: '',
- displayName: '',
- description: '',
- language: '',
- projectType: '',
- provider: '',
- tags: [],
- website: '',
- supportUrl: ''
- },
- projects: [],
- components: [],
- commands: [],
- events: {
- preStart: [],
- postStart: [],
- preStop: [],
- postStop: []
- },
- variables: {},
- starterProjects: [],
- attributes: {}
- },
- validationErrors: {}
+ devfileData: createInitialDevfileData()
}
-const WIZARD_ACTIONS = {
+export const WIZARD_ACTIONS = {
NEXT_STEP: 'NEXT_STEP',
PREV_STEP: 'PREV_STEP',
GOTO_STEP: 'GOTO_STEP',
+ MARK_STEP_COMPLETED: 'MARK_STEP_COMPLETED',
UPDATE_METADATA: 'UPDATE_METADATA',
- ADD_PROJECT: 'ADD_PROJECT',
- REMOVE_PROJECT: 'REMOVE_PROJECT',
- UPDATE_PROJECT: 'UPDATE_PROJECT',
- ADD_COMPONENT: 'ADD_COMPONENT',
- REMOVE_COMPONENT: 'REMOVE_COMPONENT',
- UPDATE_COMPONENT: 'UPDATE_COMPONENT',
- ADD_COMMAND: 'ADD_COMMAND',
- REMOVE_COMMAND: 'REMOVE_COMMAND',
- UPDATE_COMMAND: 'UPDATE_COMMAND',
UPDATE_EVENTS: 'UPDATE_EVENTS',
- UPDATE_VARIABLES: 'UPDATE_VARIABLES',
- SET_VALIDATION_ERRORS: 'SET_VALIDATION_ERRORS',
- RESET_WIZARD: 'RESET_WIZARD',
- MARK_STEP_COMPLETED: 'MARK_STEP_COMPLETED'
+ SET_SECTION: 'SET_SECTION',
+ ADD_ITEM: 'ADD_ITEM',
+ UPDATE_ITEM: 'UPDATE_ITEM',
+ REMOVE_ITEM: 'REMOVE_ITEM',
+ MOVE_ITEM: 'MOVE_ITEM',
+ RESET_WIZARD: 'RESET_WIZARD'
+}
+
+function withDevfileData(state, devfileData) {
+ return { ...state, devfileData }
}
function wizardReducer(state, action) {
@@ -61,194 +36,74 @@ function wizardReducer(state, action) {
case WIZARD_ACTIONS.NEXT_STEP:
return {
...state,
- currentStep: Math.min(state.currentStep + 1, 6),
+ currentStep: Math.min(state.currentStep + 1, LAST_STEP),
completedSteps: [...new Set([...state.completedSteps, state.currentStep])]
}
case WIZARD_ACTIONS.PREV_STEP:
- return {
- ...state,
- currentStep: Math.max(state.currentStep - 1, 0)
- }
+ return { ...state, currentStep: Math.max(state.currentStep - 1, 0) }
case WIZARD_ACTIONS.GOTO_STEP:
- return {
- ...state,
- currentStep: action.payload
- }
+ return { ...state, currentStep: Math.min(Math.max(action.payload, 0), LAST_STEP) }
case WIZARD_ACTIONS.MARK_STEP_COMPLETED:
- return {
- ...state,
- completedSteps: [...new Set([...state.completedSteps, action.payload])]
- }
+ return { ...state, completedSteps: [...new Set([...state.completedSteps, action.payload])] }
case WIZARD_ACTIONS.UPDATE_METADATA:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- metadata: {
- ...state.devfileData.metadata,
- ...action.payload
- }
- }
- }
-
- case WIZARD_ACTIONS.ADD_PROJECT:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- projects: [...state.devfileData.projects, action.payload]
- }
- }
-
- case WIZARD_ACTIONS.REMOVE_PROJECT:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- projects: state.devfileData.projects.filter((_, index) => index !== action.payload)
- }
- }
-
- case WIZARD_ACTIONS.UPDATE_PROJECT:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- projects: state.devfileData.projects.map((project, index) =>
- index === action.payload.index
- ? { ...project, ...action.payload.data }
- : project
- )
- }
- }
-
- case WIZARD_ACTIONS.ADD_COMPONENT:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- components: [...state.devfileData.components, action.payload]
- }
- }
-
- case WIZARD_ACTIONS.REMOVE_COMPONENT:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- components: state.devfileData.components.filter((_, index) => index !== action.payload)
- }
- }
-
- case WIZARD_ACTIONS.UPDATE_COMPONENT:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- components: state.devfileData.components.map((component, index) => {
- if (index !== action.payload.index) {
- return component
- }
-
- // If the update contains a type-specific property (container, volume, etc.),
- // it's a full replacement (e.g., changing component type)
- const isFullReplacement = ['container', 'volume', 'kubernetes', 'openshift', 'image']
- .some(key => key in action.payload.data)
-
- if (isFullReplacement) {
- // Full replacement: use only the new data, preserving the name if it exists
- return {
- name: component.name,
- ...action.payload.data
- }
- }
-
- // Partial update: merge with existing component
- return { ...component, ...action.payload.data }
- })
- }
- }
-
- case WIZARD_ACTIONS.ADD_COMMAND:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- commands: [...state.devfileData.commands, action.payload]
- }
- }
-
- case WIZARD_ACTIONS.REMOVE_COMMAND:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- commands: state.devfileData.commands.filter((_, index) => index !== action.payload)
- }
- }
-
- case WIZARD_ACTIONS.UPDATE_COMMAND:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- commands: state.devfileData.commands.map((command, index) => {
- if (index !== action.payload.index) {
- return command
- }
-
- // If the update contains a type-specific property (exec, apply, composite),
- // it's a full replacement (e.g., changing command type)
- const isFullReplacement = ['exec', 'apply', 'composite']
- .some(key => key in action.payload.data)
-
- if (isFullReplacement) {
- // Full replacement: use only the new data, preserving the id if it exists
- return {
- id: command.id,
- ...action.payload.data
- }
- }
-
- // Partial update: merge with existing command
- return { ...command, ...action.payload.data }
- })
- }
- }
+ return withDevfileData(state, {
+ ...state.devfileData,
+ metadata: { ...state.devfileData.metadata, ...action.payload }
+ })
case WIZARD_ACTIONS.UPDATE_EVENTS:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- events: {
- ...state.devfileData.events,
- ...action.payload
- }
- }
- }
-
- case WIZARD_ACTIONS.UPDATE_VARIABLES:
- return {
- ...state,
- devfileData: {
- ...state.devfileData,
- variables: action.payload
- }
- }
-
- case WIZARD_ACTIONS.SET_VALIDATION_ERRORS:
- return {
- ...state,
- validationErrors: action.payload
- }
+ return withDevfileData(state, {
+ ...state.devfileData,
+ events: { ...state.devfileData.events, ...action.payload }
+ })
+
+ case WIZARD_ACTIONS.SET_SECTION:
+ return withDevfileData(state, {
+ ...state.devfileData,
+ [action.payload.section]: action.payload.value
+ })
+
+ case WIZARD_ACTIONS.ADD_ITEM:
+ return withDevfileData(state, {
+ ...state.devfileData,
+ [action.payload.section]: [...state.devfileData[action.payload.section], action.payload.value]
+ })
+
+ case WIZARD_ACTIONS.UPDATE_ITEM:
+ return withDevfileData(state, {
+ ...state.devfileData,
+ [action.payload.section]: state.devfileData[action.payload.section].map((item, index) =>
+ index === action.payload.index ? action.payload.value : item
+ )
+ })
+
+ case WIZARD_ACTIONS.REMOVE_ITEM:
+ return withDevfileData(state, {
+ ...state.devfileData,
+ [action.payload.section]: state.devfileData[action.payload.section].filter(
+ (_, index) => index !== action.payload.index
+ )
+ })
+
+ case WIZARD_ACTIONS.MOVE_ITEM: {
+ const { section, index, offset } = action.payload
+ const items = state.devfileData[section]
+ const target = index + offset
+
+ if (target < 0 || target >= items.length) return state
+
+ const reordered = [...items]
+ ;[reordered[index], reordered[target]] = [reordered[target], reordered[index]]
+
+ return withDevfileData(state, { ...state.devfileData, [section]: reordered })
+ }
case WIZARD_ACTIONS.RESET_WIZARD:
- return initialState
+ return { ...initialState, devfileData: createInitialDevfileData() }
default:
return state
@@ -258,11 +113,9 @@ function wizardReducer(state, action) {
export function WizardProvider({ children }) {
const [state, dispatch] = useReducer(wizardReducer, initialState)
- return (
-
- {children}
-
- )
+ const value = useMemo(() => ({ state, dispatch, actions: WIZARD_ACTIONS }), [state])
+
+ return
{children}
}
export function useWizard() {
@@ -272,3 +125,20 @@ export function useWizard() {
}
return context
}
+
+/**
+ * Helpers for the array-shaped devfile sections (projects, components, ...).
+ * `section` is a top-level key of `devfileData`.
+ */
+export function useSectionList(section) {
+ const { state, dispatch } = useWizard()
+ const items = state.devfileData[section]
+
+ return {
+ items,
+ add: (value) => dispatch({ type: WIZARD_ACTIONS.ADD_ITEM, payload: { section, value } }),
+ update: (index, value) => dispatch({ type: WIZARD_ACTIONS.UPDATE_ITEM, payload: { section, index, value } }),
+ remove: (index) => dispatch({ type: WIZARD_ACTIONS.REMOVE_ITEM, payload: { section, index } }),
+ move: (index, offset) => dispatch({ type: WIZARD_ACTIONS.MOVE_ITEM, payload: { section, index, offset } })
+ }
+}
diff --git a/src/hooks/useYamlGenerator.jsx b/src/hooks/useYamlGenerator.jsx
deleted file mode 100644
index 8a5cfe0..0000000
--- a/src/hooks/useYamlGenerator.jsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import yaml from 'js-yaml'
-import { cleanDevfileData } from '../utils/devfileGenerator'
-
-export function useYamlGenerator() {
- const generateYaml = (devfileData) => {
- const cleaned = cleanDevfileData(devfileData)
-
- const yamlString = yaml.dump(cleaned, {
- indent: 2,
- lineWidth: 80,
- noRefs: true,
- sortKeys: false
- })
-
- return yamlString
- }
-
- return { generateYaml }
-}
diff --git a/src/utils/constants.js b/src/utils/constants.js
index d1612e9..6f07b63 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -1,40 +1,56 @@
+export const DEVFILE_SCHEMA_VERSION = '2.3.0'
+
export const WIZARD_STEPS = [
- { id: 'metadata', title: 'Basic Information', required: true },
- { id: 'projects', title: 'Projects', required: false },
- { id: 'components', title: 'Components', required: false },
- { id: 'commands', title: 'Commands', required: false },
- { id: 'events', title: 'Events', required: false },
- { id: 'variables', title: 'Variables', required: false },
- { id: 'review', title: 'Review & Download', required: true }
+ { id: 'metadata', title: 'Basics', heading: 'Basic Information', required: true },
+ { id: 'parent', title: 'Parent', heading: 'Parent', required: false },
+ { id: 'projects', title: 'Projects', heading: 'Projects', required: false },
+ { id: 'components', title: 'Components', heading: 'Components', required: false },
+ { id: 'commands', title: 'Commands', heading: 'Commands', required: false },
+ { id: 'events', title: 'Events', heading: 'Events', required: false },
+ { id: 'variables', title: 'Variables', heading: 'Variables', required: false },
+ { id: 'review', title: 'Review', heading: 'Review & Download', required: true }
]
-export const LANGUAGES = [
- { value: 'JavaScript', label: 'JavaScript' },
- { value: 'TypeScript', label: 'TypeScript' },
- { value: 'Python', label: 'Python' },
- { value: 'Java', label: 'Java' },
- { value: 'Go', label: 'Go' },
- { value: 'C#', label: 'C#' },
- { value: 'C++', label: 'C++' },
- { value: 'Ruby', label: 'Ruby' },
- { value: 'PHP', label: 'PHP' },
- { value: 'Rust', label: 'Rust' },
- { value: 'Swift', label: 'Swift' },
- { value: 'Kotlin', label: 'Kotlin' }
+// metadata.language and metadata.projectType are free-form strings in the schema,
+// these are only suggestions offered through a datalist.
+export const LANGUAGE_SUGGESTIONS = [
+ 'JavaScript',
+ 'TypeScript',
+ 'Python',
+ 'Java',
+ 'Go',
+ 'C#',
+ 'C++',
+ 'Ruby',
+ 'PHP',
+ 'Rust',
+ 'Swift',
+ 'Kotlin',
+ 'Scala',
+ '.NET'
]
-export const PROJECT_TYPES = [
- { value: 'application', label: 'Application' },
- { value: 'library', label: 'Library' },
- { value: 'tool', label: 'Tool' },
- { value: 'framework', label: 'Framework' }
+export const PROJECT_TYPE_SUGGESTIONS = [
+ 'application',
+ 'library',
+ 'tool',
+ 'framework',
+ 'nodejs',
+ 'quarkus',
+ 'spring',
+ 'django',
+ 'dotnet',
+ 'vertx'
]
+// metadata.architectures enum
+export const ARCHITECTURES = ['amd64', 'arm64', 'ppc64le', 's390x']
+
export const COMPONENT_TYPES = [
{ value: 'container', label: 'Container' },
- { value: 'volume', label: 'Volume' },
{ value: 'kubernetes', label: 'Kubernetes' },
{ value: 'openshift', label: 'OpenShift' },
+ { value: 'volume', label: 'Volume' },
{ value: 'image', label: 'Image' }
]
@@ -44,24 +60,106 @@ export const COMMAND_TYPES = [
{ value: 'composite', label: 'Composite' }
]
+// command group kind enum
export const COMMAND_GROUPS = [
{ value: 'build', label: 'Build' },
{ value: 'run', label: 'Run' },
{ value: 'test', label: 'Test' },
- { value: 'debug', label: 'Debug' }
+ { value: 'debug', label: 'Debug' },
+ { value: 'deploy', label: 'Deploy' }
]
+// endpoint protocol enum (default: http)
export const ENDPOINT_PROTOCOLS = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
+ { value: 'ws', label: 'WebSocket (ws)' },
+ { value: 'wss', label: 'Secure WebSocket (wss)' },
{ value: 'tcp', label: 'TCP' },
- { value: 'udp', label: 'UDP' },
- { value: 'ws', label: 'WebSocket' }
+ { value: 'udp', label: 'UDP' }
+]
+
+// endpoint exposure enum (default: public)
+export const ENDPOINT_EXPOSURES = [
+ { value: 'public', label: 'Public' },
+ { value: 'internal', label: 'Internal' },
+ { value: 'none', label: 'None' }
+]
+
+export const PROJECT_SOURCE_TYPES = [
+ { value: 'git', label: 'Git Repository' },
+ { value: 'zip', label: 'Zip Archive' }
+]
+
+// kubernetes/openshift components take their manifest either from a uri or inlined
+export const K8S_SOURCE_TYPES = [
+ { value: 'uri', label: 'URI (external file)' },
+ { value: 'inlined', label: 'Inlined manifest' }
+]
+
+// image component dockerfile sources
+export const DOCKERFILE_SOURCE_TYPES = [
+ { value: 'uri', label: 'URI' },
+ { value: 'devfileRegistry', label: 'Devfile registry' },
+ { value: 'git', label: 'Git repository' }
+]
+
+// An image component inside a parent override either redefines the build or
+// only flips autoBuild, the schema rejects doing both at once.
+export const IMAGE_OVERRIDE_TYPES = [
+ { value: 'dockerfile', label: 'Dockerfile build' },
+ { value: 'autoBuild', label: 'autoBuild only' }
+]
+
+export const PARENT_SOURCE_TYPES = [
+ { value: 'uri', label: 'URI' },
+ { value: 'id', label: 'Registry stack id' },
+ { value: 'kubernetes', label: 'Kubernetes DevWorkspaceTemplate' }
]
export const EVENT_TYPES = [
- { value: 'preStart', label: 'Pre Start' },
- { value: 'postStart', label: 'Post Start' },
- { value: 'preStop', label: 'Pre Stop' },
- { value: 'postStop', label: 'Post Stop' }
+ {
+ value: 'preStart',
+ label: 'Pre Start',
+ description: 'Commands executed in an init container before the workspace starts. Must be apply commands (or composites of apply commands).',
+ allowed: 'apply'
+ },
+ {
+ value: 'postStart',
+ label: 'Post Start',
+ description: 'Commands executed after the workspace starts. Must be exec commands (or composites of exec commands).',
+ allowed: 'exec'
+ },
+ {
+ value: 'preStop',
+ label: 'Pre Stop',
+ description: 'Commands executed before the workspace stops. Must be exec commands (or composites of exec commands).',
+ allowed: 'exec'
+ },
+ {
+ value: 'postStop',
+ label: 'Post Stop',
+ description: 'Commands executed after the workspace stops. Must be apply commands (or composites of apply commands).',
+ allowed: 'apply'
+ }
]
+
+// Environment variables that are set by the platform and must not be redefined
+// in a container component's env list.
+export const RESERVED_ENV_NAMES = ['PROJECT_SOURCE', 'PROJECTS_ROOT']
+
+// Shared by component names, command ids, project names and endpoint names.
+export const K8S_NAME_PATTERN = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
+
+// The schema does not constrain variable keys, but `{{key}}` references are
+// only readable with these characters, which is what the Variables step says.
+export const VARIABLE_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/
+
+export const SCHEMA_VERSION_PATTERN =
+ /^([2-9])\.([0-9]+)\.([0-9]+)(-[0-9a-z-]+(\.[0-9a-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
+
+export const METADATA_VERSION_PATTERN =
+ /^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9a-z-]+(\.[0-9a-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
+
+export const PARENT_VERSION_PATTERN =
+ /^(latest)|(([1-9])\.([0-9]+)\.([0-9]+)(-[0-9a-z-]+(\.[0-9a-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?)$/
diff --git a/src/utils/devfileGenerator.js b/src/utils/devfileGenerator.js
index 1bcace9..62e6922 100644
--- a/src/utils/devfileGenerator.js
+++ b/src/utils/devfileGenerator.js
@@ -1,169 +1,1300 @@
-function hasContent(obj) {
- if (!obj) return false
- if (typeof obj !== 'object') return !!obj
- if (Array.isArray(obj)) return obj.length > 0
- return Object.keys(obj).length > 0 && Object.values(obj).some(v => v !== '' && v !== null && v !== undefined)
+import yaml from 'js-yaml'
+import {
+ ARCHITECTURES,
+ COMMAND_GROUPS,
+ ENDPOINT_EXPOSURES,
+ ENDPOINT_PROTOCOLS,
+ K8S_NAME_PATTERN,
+ METADATA_VERSION_PATTERN,
+ PARENT_VERSION_PATTERN,
+ RESERVED_ENV_NAMES,
+ SCHEMA_VERSION_PATTERN,
+ VARIABLE_KEY_PATTERN
+} from './constants'
+import { getCommandType, getComponentType } from './factories'
+
+/* ------------------------------------------------------------------ *
+ * Primitive helpers
+ * ------------------------------------------------------------------ */
+
+function str(value) {
+ if (typeof value !== 'string') return undefined
+ const trimmed = value.trim()
+ return trimmed === '' ? undefined : trimmed
+}
+
+function int(value) {
+ if (value === '' || value === null || value === undefined) return undefined
+ const parsed = Number(value)
+ return Number.isInteger(parsed) ? parsed : undefined
}
-function cleanObject(obj) {
- if (!obj || typeof obj !== 'object') return obj
+/** Boolean that defaults to `false` in the schema: only emitted when `true`. */
+function flag(value) {
+ return value === true ? true : undefined
+}
- if (Array.isArray(obj)) {
- const cleaned = obj.map(cleanObject).filter(item => item !== null && item !== undefined)
- return cleaned.length > 0 ? cleaned : undefined
+/** Tri-state boolean: `undefined` stays unset, `true`/`false` are emitted. */
+function tri(value) {
+ return value === true || value === false ? value : undefined
+}
+
+function strList(values) {
+ if (!Array.isArray(values)) return undefined
+ const cleaned = values.map(str).filter((value) => value !== undefined)
+ return cleaned.length > 0 ? cleaned : undefined
+}
+
+/** `[{ key, value }]` -> `{ key: value }`, dropping entries without a key. */
+function strMap(entries) {
+ if (!Array.isArray(entries)) return undefined
+ const result = {}
+ for (const entry of entries) {
+ const key = str(entry?.key)
+ if (key === undefined) continue
+ result[key] = typeof entry.value === 'string' ? entry.value.trim() : ''
}
+ return Object.keys(result).length > 0 ? result : undefined
+}
+/** Drops `undefined` members; always returns an object. */
+function prune(object) {
const result = {}
- for (const [key, value] of Object.entries(obj)) {
- if (value === null || value === undefined || value === '') continue
+ for (const [key, value] of Object.entries(object)) {
+ if (value !== undefined) result[key] = value
+ }
+ return result
+}
- if (Array.isArray(value)) {
- if (value.length > 0) {
- result[key] = cleanObject(value)
- }
- } else if (typeof value === 'object') {
- const cleaned = cleanObject(value)
- if (cleaned && Object.keys(cleaned).length > 0) {
- result[key] = cleaned
- }
- } else {
- result[key] = value
- }
+/** Like `prune`, but collapses an empty result to `undefined`. */
+function pruneOrOmit(object) {
+ const pruned = prune(object)
+ return Object.keys(pruned).length > 0 ? pruned : undefined
+}
+
+function mapList(items, build) {
+ if (!Array.isArray(items) || items.length === 0) return undefined
+ const built = items.map(build).filter((item) => item !== undefined)
+ return built.length > 0 ? built : undefined
+}
+
+/**
+ * Parses the raw YAML text used for free-form `attributes` maps.
+ * Returns `{ value, error }` so that callers can report the problem without
+ * throwing while the user is still typing.
+ */
+export function parseAttributes(text) {
+ if (typeof text !== 'string' || text.trim() === '') {
+ return { value: undefined, error: null }
}
- return Object.keys(result).length > 0 ? result : undefined
+ let parsed
+ try {
+ parsed = yaml.load(text)
+ } catch (err) {
+ return { value: undefined, error: err.message }
+ }
+
+ if (parsed === null || parsed === undefined) {
+ return { value: undefined, error: null }
+ }
+
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return { value: undefined, error: 'Attributes must be a YAML map of key/value pairs' }
+ }
+
+ return { value: parsed, error: null }
+}
+
+function attributes(text) {
+ return parseAttributes(text).value
+}
+
+/* ------------------------------------------------------------------ *
+ * Element serializers
+ * ------------------------------------------------------------------ */
+
+function buildEnv(entries) {
+ return mapList(entries, (entry) => {
+ const name = str(entry?.name)
+ if (name === undefined) return undefined
+ return { name, value: typeof entry.value === 'string' ? entry.value : '' }
+ })
+}
+
+function buildVolumeMounts(entries) {
+ return mapList(entries, (entry) => {
+ const name = str(entry?.name)
+ if (name === undefined) return undefined
+ return prune({ name, path: str(entry.path) })
+ })
+}
+
+function buildEndpoints(entries) {
+ return mapList(entries, (endpoint) =>
+ pruneOrOmit({
+ name: str(endpoint?.name),
+ targetPort: int(endpoint?.targetPort),
+ exposure: str(endpoint?.exposure),
+ protocol: str(endpoint?.protocol),
+ secure: flag(endpoint?.secure),
+ path: str(endpoint?.path),
+ annotation: strMap(endpoint?.annotation),
+ attributes: attributes(endpoint?.attributes)
+ })
+ )
}
-export function cleanDevfileData(data) {
- const result = {
- schemaVersion: data.schemaVersion
+function buildContainer(container, partial) {
+ const bool = partial ? tri : flag
+
+ return prune({
+ image: str(container.image),
+ command: strList(container.command),
+ args: strList(container.args),
+ env: buildEnv(container.env),
+ volumeMounts: buildVolumeMounts(container.volumeMounts),
+ endpoints: buildEndpoints(container.endpoints),
+ memoryRequest: str(container.memoryRequest),
+ memoryLimit: str(container.memoryLimit),
+ cpuRequest: str(container.cpuRequest),
+ cpuLimit: str(container.cpuLimit),
+ mountSources: tri(container.mountSources),
+ sourceMapping: str(container.sourceMapping),
+ dedicatedPod: bool(container.dedicatedPod),
+ annotation: pruneOrOmit({
+ deployment: strMap(container.annotation?.deployment),
+ service: strMap(container.annotation?.service)
+ })
+ })
+}
+
+function buildVolume(volume, partial) {
+ const bool = partial ? tri : flag
+
+ return prune({
+ size: str(volume.size),
+ ephemeral: bool(volume.ephemeral)
+ })
+}
+
+function buildK8sLike(k8s) {
+ return prune({
+ uri: k8s.uri == null ? undefined : str(k8s.uri),
+ inlined: k8s.inlined == null ? undefined : str(k8s.inlined),
+ deployByDefault: tri(k8s.deployByDefault),
+ endpoints: buildEndpoints(k8s.endpoints)
+ })
+}
+
+function buildGitSource(git, { fileLocation = false } = {}) {
+ return prune({
+ remotes: strMap(git.remotes),
+ checkoutFrom: pruneOrOmit({
+ remote: str(git.checkoutFrom?.remote),
+ revision: str(git.checkoutFrom?.revision)
+ }),
+ fileLocation: fileLocation ? str(git.fileLocation) : undefined
+ })
+}
+
+function buildDockerfile(dockerfile, partial) {
+ const bool = partial ? tri : flag
+
+ return prune({
+ uri: dockerfile.uri == null ? undefined : str(dockerfile.uri),
+ devfileRegistry:
+ dockerfile.devfileRegistry == null
+ ? undefined
+ : prune({
+ id: str(dockerfile.devfileRegistry.id),
+ registryUrl: str(dockerfile.devfileRegistry.registryUrl)
+ }),
+ git: dockerfile.git == null ? undefined : buildGitSource(dockerfile.git, { fileLocation: true }),
+ buildContext: str(dockerfile.buildContext),
+ rootRequired: bool(dockerfile.rootRequired),
+ args: strList(dockerfile.args)
+ })
+}
+
+function buildImage(image, partial) {
+ const built = prune({
+ imageName: str(image.imageName),
+ autoBuild: tri(image.autoBuild)
+ })
+
+ // `dockerfile` is the only image source in 2.3.0; parent overrides may set
+ // `autoBuild` alone, so an empty dockerfile is dropped there.
+ const dockerfile = image.dockerfile ? buildDockerfile(image.dockerfile, partial) : undefined
+ if (dockerfile && (!partial || Object.keys(dockerfile).length > 0)) {
+ built.dockerfile = dockerfile
+ }
+
+ return built
+}
+
+export function buildComponent(component, partial = false) {
+ const type = getComponentType(component)
+ const built = prune({
+ name: str(component.name),
+ attributes: attributes(component.attributes)
+ })
+
+ switch (type) {
+ case 'volume':
+ built.volume = buildVolume(component.volume, partial)
+ break
+ case 'kubernetes':
+ built.kubernetes = buildK8sLike(component.kubernetes)
+ break
+ case 'openshift':
+ built.openshift = buildK8sLike(component.openshift)
+ break
+ case 'image':
+ built.image = buildImage(component.image, partial)
+ break
+ case 'container':
+ default:
+ built.container = buildContainer(component.container, partial)
+ break
+ }
+
+ return built
+}
+
+function buildCommandGroup(group, partial) {
+ const bool = partial ? tri : flag
+ const kind = str(group?.kind)
+ if (kind === undefined) return undefined
+
+ return prune({ kind, isDefault: bool(group.isDefault) })
+}
+
+export function buildCommand(command, partial = false) {
+ const type = getCommandType(command)
+ const bool = partial ? tri : flag
+ const built = prune({
+ id: str(command.id),
+ attributes: attributes(command.attributes)
+ })
+
+ switch (type) {
+ case 'apply':
+ built.apply = prune({
+ component: str(command.apply.component),
+ label: str(command.apply.label),
+ group: buildCommandGroup(command.apply.group, partial)
+ })
+ break
+ case 'composite':
+ built.composite = prune({
+ commands: strList(command.composite.commands),
+ parallel: bool(command.composite.parallel),
+ label: str(command.composite.label),
+ group: buildCommandGroup(command.composite.group, partial)
+ })
+ break
+ case 'exec':
+ default:
+ built.exec = prune({
+ commandLine: str(command.exec.commandLine),
+ component: str(command.exec.component),
+ workingDir: str(command.exec.workingDir),
+ env: buildEnv(command.exec.env),
+ hotReloadCapable: bool(command.exec.hotReloadCapable),
+ label: str(command.exec.label),
+ group: buildCommandGroup(command.exec.group, partial)
+ })
+ break
+ }
+
+ return built
+}
+
+export function buildProject(project, kind = 'projects') {
+ const built = prune({
+ name: str(project.name),
+ attributes: attributes(project.attributes),
+ clonePath: kind === 'starterProjects' ? undefined : str(project.clonePath),
+ description: kind === 'starterProjects' ? str(project.description) : undefined,
+ subDir: kind === 'starterProjects' ? str(project.subDir) : undefined
+ })
+
+ if (project.zip != null) {
+ built.zip = prune({ location: str(project.zip.location) })
+ } else {
+ built.git = buildGitSource(project.git || {})
}
- // Add metadata if any fields are populated
- if (hasContent(data.metadata)) {
- const metadata = cleanObject(data.metadata)
- if (metadata && Object.keys(metadata).length > 0) {
- result.metadata = metadata
+ return built
+}
+
+function buildProjectList(projects, kind) {
+ return mapList(projects, (project) => buildProject(project, kind))
+}
+
+function buildMetadata(metadata) {
+ return pruneOrOmit({
+ name: str(metadata.name),
+ generateName: str(metadata.generateName),
+ version: str(metadata.version),
+ displayName: str(metadata.displayName),
+ description: str(metadata.description),
+ icon: str(metadata.icon),
+ language: str(metadata.language),
+ projectType: str(metadata.projectType),
+ provider: str(metadata.provider),
+ tags: strList(metadata.tags),
+ architectures: strList(metadata.architectures),
+ website: str(metadata.website),
+ supportUrl: str(metadata.supportUrl),
+ globalMemoryLimit: str(metadata.globalMemoryLimit),
+ attributes: attributes(metadata.attributes)
+ })
+}
+
+function buildParent(parent) {
+ if (!parent) return undefined
+
+ return pruneOrOmit({
+ uri: parent.uri == null ? undefined : str(parent.uri),
+ id: parent.id == null ? undefined : str(parent.id),
+ kubernetes:
+ parent.kubernetes == null
+ ? undefined
+ : prune({
+ name: str(parent.kubernetes.name),
+ namespace: str(parent.kubernetes.namespace)
+ }),
+ registryUrl: parent.id == null ? undefined : str(parent.registryUrl),
+ version: parent.id == null ? undefined : str(parent.version),
+ attributes: attributes(parent.attributes),
+ variables: strMap(parent.variables),
+ projects: buildProjectList(parent.projects, 'projects'),
+ starterProjects: buildProjectList(parent.starterProjects, 'starterProjects'),
+ dependentProjects: buildProjectList(parent.dependentProjects, 'dependentProjects'),
+ components: mapList(parent.components, (component) => buildComponent(component, true)),
+ commands: mapList(parent.commands, (command) => buildCommand(command, true))
+ })
+}
+
+function buildEvents(events) {
+ if (!events) return undefined
+
+ return pruneOrOmit({
+ preStart: strList(events.preStart),
+ postStart: strList(events.postStart),
+ preStop: strList(events.preStop),
+ postStop: strList(events.postStop)
+ })
+}
+
+/**
+ * Turns the wizard state into a plain devfile 2.3.0 object.
+ *
+ * Only schema fields are ever emitted: the serializer walks the schema
+ * explicitly instead of cleaning up the UI state, so wizard-only bookkeeping
+ * can never leak into the generated YAML.
+ */
+export function buildDevfile(data) {
+ return prune({
+ schemaVersion: str(data.schemaVersion),
+ metadata: buildMetadata(data.metadata || {}),
+ attributes: attributes(data.attributes),
+ parent: buildParent(data.parent),
+ projects: buildProjectList(data.projects, 'projects'),
+ starterProjects: buildProjectList(data.starterProjects, 'starterProjects'),
+ dependentProjects: buildProjectList(data.dependentProjects, 'dependentProjects'),
+ components: mapList(data.components, (component) => buildComponent(component, false)),
+ commands: mapList(data.commands, (command) => buildCommand(command, false)),
+ events: buildEvents(data.events),
+ variables: strMap(data.variables)
+ })
+}
+
+export function generateYaml(data) {
+ return yaml.dump(buildDevfile(data), {
+ indent: 2,
+ lineWidth: -1,
+ noRefs: true,
+ sortKeys: false
+ })
+}
+
+/* ------------------------------------------------------------------ *
+ * Validation
+ *
+ * Mirrors the rules enforced by devfile/api
+ * (pkg/validation/validation-rule.md) on top of the JSON schema constraints.
+ * ------------------------------------------------------------------ */
+
+/** The component types that own an `endpoints` list. */
+const ENDPOINT_HOLDERS = ['container', 'kubernetes', 'openshift']
+
+// The schema enums. The wizard only offers them through selects, but the
+// serializer must never be the only thing standing between a hand-made state
+// and an invalid devfile.
+const PROTOCOLS = ENDPOINT_PROTOCOLS.map((protocol) => protocol.value)
+const EXPOSURES = ENDPOINT_EXPOSURES.map((exposure) => exposure.value)
+const GROUP_KINDS = COMMAND_GROUPS.map((group) => group.value)
+
+/** The three project lists, with the label their messages use. */
+const PROJECT_LABELS = [
+ ['projects', 'Project'],
+ ['starterProjects', 'Starter project'],
+ ['dependentProjects', 'Dependent project']
+]
+
+const QUANTITY_PATTERN =
+ /^([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))(?:([eE][+-]?[0-9]+)|(Ki|Mi|Gi|Ti|Pi|Ei)|(n|u|m|k|M|G|T|P|E))?$/
+
+const QUANTITY_SUFFIXES = {
+ n: 1e-9,
+ u: 1e-6,
+ m: 1e-3,
+ k: 1e3,
+ M: 1e6,
+ G: 1e9,
+ T: 1e12,
+ P: 1e15,
+ E: 1e18,
+ Ki: 2 ** 10,
+ Mi: 2 ** 20,
+ Gi: 2 ** 30,
+ Ti: 2 ** 40,
+ Pi: 2 ** 50,
+ Ei: 2 ** 60
+}
+
+/** Parses a Kubernetes resource quantity, returning `null` when invalid. */
+export function parseQuantity(value) {
+ if (typeof value !== 'string') return null
+
+ const match = QUANTITY_PATTERN.exec(value.trim())
+ if (!match) return null
+
+ const [, mantissa, exponent, binarySuffix, decimalSuffix] = match
+ const base = Number(mantissa)
+ if (!Number.isFinite(base)) return null
+
+ if (exponent) return base * 10 ** Number(exponent.slice(1))
+
+ const suffix = binarySuffix || decimalSuffix
+ return suffix ? base * QUANTITY_SUFFIXES[suffix] : base
+}
+
+function isValidUri(value) {
+ if (typeof value !== 'string' || value.trim() === '') return false
+ const uri = value.trim()
+
+ if (/\s/.test(uri)) return false
+
+ if (uri.startsWith('http')) {
+ try {
+ const parsed = new URL(uri)
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ } catch {
+ return false
}
}
- // Add projects if array not empty
- if (data.projects && data.projects.length > 0) {
- const projects = cleanObject(data.projects)
- if (projects && projects.length > 0) {
- result.projects = projects
+ return true
+}
+
+function isValidName(value) {
+ return typeof value === 'string' && K8S_NAME_PATTERN.test(value) && value.length <= 63
+}
+
+/** `Component "tools"` or, while it has no name yet, `Component #2`. */
+function elementLabel(kind, name, index) {
+ return `${kind} ${name ? `"${name}"` : `#${index + 1}`}`
+}
+
+/** Reports a value the schema restricts to a fixed set of alternatives. */
+function checkEnum(value, allowed, path, errors) {
+ if (value === undefined || allowed.includes(value)) return
+ errors.push(`${path} "${value}" must be one of ${allowed.join(', ')}`)
+}
+
+function checkDuplicates(values, prefix, field, errors) {
+ const seen = new Set()
+ const reported = new Set()
+ for (const value of values) {
+ if (value === undefined) continue
+ if (seen.has(value) && !reported.has(value)) {
+ errors.push(`${prefix}: duplicate ${field} "${value}"`)
+ reported.add(value)
}
+ seen.add(value)
}
+}
+
+function validateAttributeText(text, path, errors) {
+ const { error } = parseAttributes(text)
+ if (error) errors.push(`${path}: invalid attributes YAML — ${error}`)
+}
+
+/** "a volume mount", "an environment variable". */
+function withArticle(noun) {
+ return `${/^[aeiou]/i.test(noun) ? 'an' : 'a'} ${noun}`
+}
+
+/**
+ * Checks a `[{ key, value }]` map held by the wizard state.
+ *
+ * Serializing collapses duplicate keys and drops entries without one, so both
+ * have to be reported on the state rather than on the generated devfile.
+ */
+function checkMapEntries(entries, where, what, errors) {
+ if (!Array.isArray(entries)) return
+
+ const seen = new Set()
+ const reported = new Set()
+ let missingKeyReported = false
- // Add starterProjects if array not empty
- if (data.starterProjects && data.starterProjects.length > 0) {
- const starterProjects = cleanObject(data.starterProjects)
- if (starterProjects && starterProjects.length > 0) {
- result.starterProjects = starterProjects
+ for (const entry of entries) {
+ const key = str(entry?.key)
+
+ if (key === undefined) {
+ if (!missingKeyReported && str(entry?.value) !== undefined) {
+ errors.push(`${where}: ${withArticle(what)} has a value but no name`)
+ missingKeyReported = true
+ }
+ continue
}
+
+ if (seen.has(key) && !reported.has(key)) {
+ errors.push(`${where}: duplicate ${what} "${key}"`)
+ reported.add(key)
+ }
+ seen.add(key)
}
+}
- // Add components if array not empty
- if (data.components && data.components.length > 0) {
- const components = cleanObject(data.components)
- if (components && components.length > 0) {
- result.components = components
+/** Same, for the lists keyed by `name`, where a nameless entry is dropped. */
+function checkNamedEntries(entries, where, what, hasValue, errors) {
+ if (!Array.isArray(entries)) return
+
+ for (const entry of entries) {
+ if (str(entry?.name) !== undefined) continue
+ if (hasValue(entry)) {
+ errors.push(`${where}: ${withArticle(what)} has a value but no name`)
+ return
}
}
+}
+
+function validateGitRemotes(git, { label, singleRemote, partial }, errors) {
+ const remotes = git?.remotes ? Object.keys(git.remotes) : []
+ const checkoutRemote = git?.checkoutFrom?.remote
+
+ if (remotes.length === 0) {
+ // Overrides may rely on the remotes declared by the parent.
+ if (!partial) errors.push(`${label}: a git source needs at least one remote`)
+ return
+ }
+
+ for (const [remote, url] of Object.entries(git.remotes)) {
+ if (url === '') errors.push(`${label}: git remote "${remote}" has no URL`)
+ }
+
+ if (singleRemote && remotes.length > 1) {
+ errors.push(`${label}: a git source can only have one remote`)
+ return
+ }
+
+ if (!singleRemote && remotes.length > 1 && !checkoutRemote) {
+ errors.push(`${label}: checkoutFrom.remote is required when more than one remote is configured`)
+ return
+ }
+
+ if (checkoutRemote && !remotes.includes(checkoutRemote)) {
+ errors.push(`${label}: checkoutFrom.remote "${checkoutRemote}" is not one of the configured remotes`)
+ }
+}
+
+function validateProjects(projects, { kind, label, partial }, errors, warnings) {
+ if (!projects) return
+
+ checkDuplicates(projects.map((project) => project.name), `${label}s`, 'name', errors)
+
+ projects.forEach((project, index) => {
+ const name = project.name
+ const where = elementLabel(label, name, index)
+
+ if (!name) {
+ errors.push(`${where}: name is required`)
+ } else if (!isValidName(name)) {
+ errors.push(`${where}: name must be lowercase alphanumeric with dashes, max 63 characters`)
+ }
+
+ const sources = ['git', 'zip'].filter((key) => project[key] !== undefined)
+ if (sources.length !== 1) {
+ errors.push(`${where}: exactly one source (git or zip) must be configured`)
+ return
+ }
+
+ if (project.git) {
+ validateGitRemotes(project.git, { label: where, singleRemote: kind === 'starterProjects', partial }, errors)
+ }
+
+ if (project.zip && !project.zip.location) {
+ warnings.push(`${where}: zip source has no location`)
+ }
+ })
+}
+
+function validateEndpointList(endpoints, where, errors, state, { isContainer, dedicatedPod, partial }) {
+ if (!endpoints) return
+
+ const portsInComponent = new Set()
+
+ endpoints.forEach((endpoint, index) => {
+ const label = `${where} ${elementLabel('endpoint', endpoint.name, index)}`
+
+ if (!endpoint.name) {
+ errors.push(`${label}: name is required`)
+ } else {
+ if (!K8S_NAME_PATTERN.test(endpoint.name) || endpoint.name.length > 15) {
+ errors.push(`${label}: name must be lowercase alphanumeric with dashes, max 15 characters`)
+ }
+ if (state.endpointNames.has(endpoint.name)) {
+ errors.push(`${label}: endpoint names must be unique across all components`)
+ }
+ state.endpointNames.add(endpoint.name)
+ }
+
+ if (endpoint.targetPort === undefined) {
+ // An override can change any other field of an inherited endpoint.
+ if (!partial) errors.push(`${label}: targetPort is required and must be an integer`)
+ } else if (endpoint.targetPort < 1 || endpoint.targetPort > 65535) {
+ errors.push(`${label}: targetPort must be between 1 and 65535`)
+ } else if (isContainer && !dedicatedPod && !partial) {
+ portsInComponent.add(endpoint.targetPort)
+ }
+
+ checkEnum(endpoint.protocol, PROTOCOLS, `${label}: protocol`, errors)
+ checkEnum(endpoint.exposure, EXPOSURES, `${label}: exposure`, errors)
+ })
- // Add commands if array not empty
- if (data.commands && data.commands.length > 0) {
- const commands = cleanObject(data.commands)
- if (commands && commands.length > 0) {
- result.commands = commands
+ for (const port of portsInComponent) {
+ if (state.containerPorts.has(port)) {
+ errors.push(`${where}: targetPort ${port} is already used by another container component`)
}
+ state.containerPorts.add(port)
}
+}
+
+function validateComponents(components, errors, warnings, { hasParent, partial, label = 'Component' } = {}) {
+ if (!components) return
+
+ // References can also be satisfied by an element inherited from the parent,
+ // which this wizard never resolves, so they only warn when a parent is set.
+ const reference = hasParent || partial ? warnings : errors
- // Add events if any are defined
- if (hasContent(data.events)) {
- const events = {}
- if (data.events.preStart && data.events.preStart.length > 0) {
- events.preStart = data.events.preStart
+ checkDuplicates(components.map((component) => component.name), `${label}s`, 'name', errors)
+
+ const volumeNames = new Set(
+ components.filter((component) => component.volume !== undefined).map((component) => component.name)
+ )
+
+ const endpointState = { endpointNames: new Set(), containerPorts: new Set() }
+
+ // Containers of the shared workspace pod annotate the same deployment and
+ // service, so the same key cannot be given two different values.
+ const annotationState = { deployment: new Map(), service: new Map(), reported: new Set() }
+
+ components.forEach((component, index) => {
+ const name = component.name
+ const where = elementLabel(label, name, index)
+
+ if (!name) {
+ errors.push(`${where}: name is required`)
+ } else if (!isValidName(name)) {
+ errors.push(`${where}: name must be lowercase alphanumeric with dashes, max 63 characters`)
}
- if (data.events.postStart && data.events.postStart.length > 0) {
- events.postStart = data.events.postStart
+
+ const types = ['container', 'kubernetes', 'openshift', 'volume', 'image'].filter(
+ (key) => component[key] !== undefined
+ )
+ if (types.length !== 1) {
+ errors.push(`${where}: exactly one component type must be configured`)
+ return
}
- if (data.events.preStop && data.events.preStop.length > 0) {
- events.preStop = data.events.preStop
+
+ if (component.container) {
+ const container = component.container
+
+ if (!container.image && !partial) {
+ errors.push(`${where}: container image is required`)
+ }
+
+ for (const env of container.env || []) {
+ if (RESERVED_ENV_NAMES.includes(env.name)) {
+ errors.push(`${where}: "${env.name}" is a reserved environment variable and cannot be redefined`)
+ }
+ }
+
+ const quantities = {
+ memoryLimit: container.memoryLimit,
+ memoryRequest: container.memoryRequest,
+ cpuLimit: container.cpuLimit,
+ cpuRequest: container.cpuRequest
+ }
+ const parsed = {}
+ for (const [field, value] of Object.entries(quantities)) {
+ if (value === undefined) continue
+ const quantity = parseQuantity(value)
+ if (quantity === null) {
+ errors.push(`${where}: ${field} "${value}" is not a valid quantity (for example 1Gi, 512Mi, 500m)`)
+ } else {
+ parsed[field] = quantity
+ }
+ }
+ if (parsed.memoryRequest !== undefined && parsed.memoryLimit !== undefined && parsed.memoryRequest > parsed.memoryLimit) {
+ errors.push(`${where}: memoryRequest is greater than memoryLimit`)
+ }
+ if (parsed.cpuRequest !== undefined && parsed.cpuLimit !== undefined && parsed.cpuRequest > parsed.cpuLimit) {
+ errors.push(`${where}: cpuRequest is greater than cpuLimit`)
+ }
+
+ // The schema constrains a mount name the same way as a component name,
+ // and an override never relaxes it.
+ for (const mount of container.volumeMounts || []) {
+ if (!isValidName(mount.name)) {
+ errors.push(
+ `${where}: volume mount name "${mount.name}" must be lowercase alphanumeric with dashes, max 63 characters`
+ )
+ } else if (!volumeNames.has(mount.name)) {
+ reference.push(`${where}: volume mount "${mount.name}" does not reference a volume component`)
+ }
+ }
+
+ if (container.dedicatedPod !== true) {
+ for (const kind of ['deployment', 'service']) {
+ for (const [key, value] of Object.entries(container.annotation?.[kind] || {})) {
+ const seen = annotationState[kind]
+ if (seen.has(key) && seen.get(key) !== value && !annotationState.reported.has(`${kind}/${key}`)) {
+ errors.push(`${label}s: containers give conflicting values to the ${kind} annotation "${key}"`)
+ annotationState.reported.add(`${kind}/${key}`)
+ } else {
+ seen.set(key, value)
+ }
+ }
+ }
+ }
+
+ validateEndpointList(container.endpoints, where, errors, endpointState, {
+ isContainer: true,
+ dedicatedPod: container.dedicatedPod === true,
+ partial
+ })
}
- if (data.events.postStop && data.events.postStop.length > 0) {
- events.postStop = data.events.postStop
+
+ if (component.volume && component.volume.size !== undefined && parseQuantity(component.volume.size) === null) {
+ errors.push(`${where}: volume size "${component.volume.size}" is not a valid quantity (for example 2Gi, 1024Mi)`)
+ }
+
+ for (const key of ['kubernetes', 'openshift']) {
+ const k8s = component[key]
+ if (!k8s) continue
+
+ const sources = ['uri', 'inlined'].filter((source) => k8s[source] !== undefined)
+ if (sources.length !== 1) {
+ errors.push(`${where}: exactly one of uri or inlined must be set`)
+ }
+ if (k8s.uri !== undefined && !isValidUri(k8s.uri)) {
+ errors.push(`${where}: "${k8s.uri}" is not a valid URI`)
+ }
+ if (k8s.inlined !== undefined) {
+ try {
+ // A manifest may hold several documents separated by `---`.
+ yaml.loadAll(k8s.inlined)
+ } catch (err) {
+ errors.push(`${where}: the inlined manifest is not valid YAML — ${err.reason || err.message}`)
+ }
+ }
+
+ validateEndpointList(k8s.endpoints, where, errors, endpointState, { isContainer: false, partial })
}
- if (Object.keys(events).length > 0) {
- result.events = events
+
+ if (component.image) {
+ const image = component.image
+
+ if (!image.imageName && !partial) {
+ errors.push(`${where}: imageName is required`)
+ }
+
+ // An image override may either redefine the build or only flip autoBuild,
+ // the schema rejects doing both at once.
+ if (partial) {
+ const overrides = ['dockerfile', 'autoBuild'].filter((key) => image[key] !== undefined)
+ if (overrides.length !== 1) {
+ errors.push(`${where}: an image override must set exactly one of dockerfile or autoBuild`)
+ }
+ } else if (!image.dockerfile) {
+ errors.push(`${where}: a dockerfile build source is required`)
+ }
+
+ if (image.dockerfile) {
+ const sources = ['uri', 'devfileRegistry', 'git'].filter((key) => image.dockerfile[key] !== undefined)
+ if (sources.length === 0) {
+ errors.push(`${where}: a dockerfile source (uri, devfileRegistry or git) must be set`)
+ } else if (sources.length > 1) {
+ errors.push(`${where}: only one dockerfile source (uri, devfileRegistry or git) can be set`)
+ }
+ if (image.dockerfile.uri !== undefined && !isValidUri(image.dockerfile.uri)) {
+ errors.push(`${where}: dockerfile uri "${image.dockerfile.uri}" is not a valid URI`)
+ }
+ if (image.dockerfile.devfileRegistry && !image.dockerfile.devfileRegistry.id && !partial) {
+ errors.push(`${where}: dockerfile devfileRegistry id is required`)
+ }
+ if (image.dockerfile.git) {
+ validateGitRemotes(
+ image.dockerfile.git,
+ { label: `${where} dockerfile`, singleRemote: true, partial },
+ errors
+ )
+ }
+ if (!image.dockerfile.buildContext && !partial) {
+ warnings.push(`${where}: no buildContext set for the dockerfile build`)
+ }
+ }
}
+ })
+}
+
+function validateCommands(commands, components, errors, warnings, { hasParent, partial, label = 'Command' } = {}) {
+ if (!commands) return
+
+ const reference = hasParent || partial ? warnings : errors
+
+ checkDuplicates(commands.map((command) => command.id), `${label}s`, 'id', errors)
+
+ const commandsById = new Map()
+ for (const command of commands) {
+ if (command.id) commandsById.set(command.id, command)
}
- // Add variables if any are defined
- if (hasContent(data.variables)) {
- result.variables = data.variables
+ const componentsByName = new Map()
+ for (const component of components || []) {
+ if (component.name) componentsByName.set(component.name, component)
}
- // Add attributes if any are defined
- if (hasContent(data.attributes)) {
- const attributes = cleanObject(data.attributes)
- if (attributes && Object.keys(attributes).length > 0) {
- result.attributes = attributes
+ const groups = new Map()
+
+ commands.forEach((command, index) => {
+ const id = command.id
+ const where = elementLabel(label, id, index)
+
+ if (!id) {
+ errors.push(`${where}: id is required`)
+ } else if (!isValidName(id)) {
+ errors.push(`${where}: id must be lowercase alphanumeric with dashes, max 63 characters`)
}
- }
- return result
-}
+ const types = ['exec', 'apply', 'composite'].filter((key) => command[key] !== undefined)
+ if (types.length !== 1) {
+ errors.push(`${where}: exactly one command type must be configured`)
+ return
+ }
-export function validateDevfile(data) {
- const errors = []
+ const [type] = types
+ const body = command[type]
- // Schema version is always required
- if (!data.schemaVersion) {
- errors.push('Schema version is required')
+ if (body.group?.kind) {
+ const kind = body.group.kind
+ checkEnum(kind, GROUP_KINDS, `${where}: group kind`, errors)
+ if (!groups.has(kind)) groups.set(kind, [])
+ groups.get(kind).push({ id: id || `#${index + 1}`, isDefault: body.group.isDefault === true })
+ }
+
+ if (type === 'exec') {
+ if (!body.commandLine && !partial) errors.push(`${where}: commandLine is required`)
+ if (!body.component) {
+ if (!partial) errors.push(`${where}: a component reference is required`)
+ } else {
+ const target = componentsByName.get(body.component)
+ if (!target) {
+ reference.push(`${where}: references unknown component "${body.component}"`)
+ } else if (!target.container) {
+ errors.push(`${where}: exec commands must reference a container component, "${body.component}" is not one`)
+ }
+ }
+ for (const env of body.env || []) {
+ if (RESERVED_ENV_NAMES.includes(env.name)) {
+ errors.push(`${where}: "${env.name}" is a reserved environment variable and cannot be redefined`)
+ }
+ }
+ }
+
+ if (type === 'apply') {
+ if (!body.component) {
+ if (!partial) errors.push(`${where}: a component reference is required`)
+ } else {
+ const target = componentsByName.get(body.component)
+ if (!target) {
+ reference.push(`${where}: references unknown component "${body.component}"`)
+ } else if (target.volume) {
+ errors.push(
+ `${where}: apply commands must reference a container, kubernetes, openshift or image component, "${body.component}" is a volume`
+ )
+ }
+ }
+ }
+
+ if (type === 'composite') {
+ if (!body.commands || body.commands.length === 0) {
+ if (!partial) errors.push(`${where}: at least one sub-command is required`)
+ } else {
+ for (const subCommand of body.commands) {
+ if (subCommand === id) {
+ errors.push(`${where}: a composite command cannot reference itself`)
+ } else if (!commandsById.has(subCommand)) {
+ reference.push(`${where}: references unknown command "${subCommand}"`)
+ }
+ }
+ const cycle = findCompositeCycle(id, commandsById)
+ if (cycle) {
+ errors.push(`${where}: a composite command cannot indirectly reference itself (${cycle.join(' -> ')})`)
+ }
+ }
+ }
+ })
+
+ for (const [kind, groupCommands] of groups) {
+ if (groupCommands.length < 2) continue
+ const defaults = groupCommands.filter((command) => command.isDefault)
+ if (defaults.length === 0) {
+ warnings.push(`${label} group "${kind}" has ${groupCommands.length} commands but no default one`)
+ } else if (defaults.length > 1) {
+ errors.push(
+ `${label} group "${kind}" has more than one default command: ${defaults.map((command) => `"${command.id}"`).join(', ')}`
+ )
+ }
}
+}
+
+/** Depth-first search for an indirect self reference between composite commands. */
+function findCompositeCycle(startId, commandsById) {
+ if (!startId) return null
+
+ const path = []
+ const visiting = new Set()
- // Check for unique component names
- if (data.components && data.components.length > 0) {
- const names = data.components.map(c => c.name).filter(Boolean)
- const duplicates = names.filter((name, index) => names.indexOf(name) !== index)
- if (duplicates.length > 0) {
- errors.push(`Duplicate component names: ${duplicates.join(', ')}`)
+ function visit(id) {
+ const command = commandsById.get(id)
+ if (!command?.composite) return false
+
+ path.push(id)
+ visiting.add(id)
+
+ for (const reference of command.composite.commands || []) {
+ if (reference === startId) {
+ path.push(reference)
+ return true
+ }
+ if (!visiting.has(reference) && visit(reference)) return true
}
+
+ path.pop()
+ visiting.delete(id)
+ return false
}
- // Check for unique command IDs
- if (data.commands && data.commands.length > 0) {
- const ids = data.commands.map(c => c.id).filter(Boolean)
- const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index)
- if (duplicates.length > 0) {
- errors.push(`Duplicate command IDs: ${duplicates.join(', ')}`)
+ return visit(startId) && path.length > 2 ? [...path] : null
+}
+
+function validateEventList(eventType, commandIds, commandsById, errors, warnings, reference) {
+ if (!commandIds) return
+
+ const expected = eventType === 'preStart' || eventType === 'postStop' ? 'apply' : 'exec'
+ const seen = new Set()
+ const reportedDuplicates = new Set()
+
+ for (const id of commandIds) {
+ if (seen.has(id) && !reportedDuplicates.has(id)) {
+ warnings.push(`Event ${eventType}: "${id}" is bound more than once`)
+ reportedDuplicates.add(id)
+ }
+ seen.add(id)
+
+ const command = commandsById.get(id)
+ if (!command) {
+ reference.push(`Event ${eventType}: "${id}" does not map to a devfile command`)
+ continue
}
+
+ if (command[expected]) continue
+
+ if (command.composite) {
+ const invalid = (command.composite.commands || []).filter((reference) => {
+ const sub = commandsById.get(reference)
+ return sub && !sub[expected]
+ })
+ if (invalid.length > 0) {
+ errors.push(
+ `Event ${eventType}: composite command "${id}" must only contain ${expected} commands (${invalid.join(', ')})`
+ )
+ }
+ continue
+ }
+
+ errors.push(
+ `Event ${eventType}: "${id}" must be an ${expected} command, or a composite command of ${expected} commands`
+ )
+ }
+}
+
+function validateEvents(events, commands, errors, warnings, { hasParent } = {}) {
+ if (!events) return
+
+ const commandsById = new Map()
+ for (const command of commands || []) {
+ if (command.id) commandsById.set(command.id, command)
+ }
+
+ for (const eventType of ['preStart', 'postStart', 'preStop', 'postStop']) {
+ validateEventList(
+ eventType,
+ events[eventType],
+ commandsById,
+ errors,
+ warnings,
+ hasParent ? warnings : errors
+ )
+ }
+}
+
+function validateMetadataSection(metadata, errors, warnings) {
+ if (!metadata) {
+ warnings.push('Metadata: no devfile name set')
+ return
+ }
+
+ if (metadata.name && metadata.generateName) {
+ errors.push('Metadata: name and generateName are mutually exclusive')
+ }
+
+ if (!metadata.name && !metadata.generateName) {
+ warnings.push('Metadata: no devfile name set')
+ }
+
+ if (metadata.name && !isValidName(metadata.name)) {
+ errors.push('Metadata: name must be lowercase alphanumeric with dashes, max 63 characters')
}
- // Check for unique project names
- if (data.projects && data.projects.length > 0) {
- const names = data.projects.map(p => p.name).filter(Boolean)
- const duplicates = names.filter((name, index) => names.indexOf(name) !== index)
- if (duplicates.length > 0) {
- errors.push(`Duplicate project names: ${duplicates.join(', ')}`)
+ if (metadata.version && !METADATA_VERSION_PATTERN.test(metadata.version)) {
+ errors.push(`Metadata: version "${metadata.version}" must follow semantic versioning (for example 1.0.0)`)
+ }
+
+ for (const field of ['website', 'supportUrl', 'icon']) {
+ if (metadata[field] && !isValidUri(metadata[field])) {
+ errors.push(`Metadata: ${field} "${metadata[field]}" is not a valid URI`)
}
}
- // Check that event-referenced commands exist
- if (data.events) {
- const commandIds = (data.commands || []).map(c => c.id).filter(Boolean)
- const allEventCommands = [
- ...(data.events.preStart || []),
- ...(data.events.postStart || []),
- ...(data.events.preStop || []),
- ...(data.events.postStop || [])
- ]
- const invalidRefs = allEventCommands.filter(cmd => !commandIds.includes(cmd))
- if (invalidRefs.length > 0) {
- errors.push(`Event references non-existent commands: ${invalidRefs.join(', ')}`)
+ for (const architecture of metadata.architectures || []) {
+ checkEnum(architecture, ARCHITECTURES, 'Metadata: architecture', errors)
+ }
+
+ if (metadata.globalMemoryLimit && parseQuantity(metadata.globalMemoryLimit) === null) {
+ errors.push(`Metadata: globalMemoryLimit "${metadata.globalMemoryLimit}" is not a valid quantity`)
+ }
+}
+
+function validateParentSection(parent, errors, warnings) {
+ if (!parent) return
+
+ const sources = ['uri', 'id', 'kubernetes'].filter((key) => parent[key] !== undefined)
+ if (sources.length !== 1) {
+ errors.push('Parent: exactly one of uri, id or kubernetes must be set')
+ }
+
+ if (parent.uri !== undefined && !isValidUri(parent.uri)) {
+ errors.push(`Parent: uri "${parent.uri}" is not a valid URI`)
+ }
+
+ if (parent.kubernetes && !parent.kubernetes.name) {
+ errors.push('Parent: the kubernetes reference needs a name')
+ }
+
+ if (parent.registryUrl && !isValidUri(parent.registryUrl)) {
+ errors.push(`Parent: registryUrl "${parent.registryUrl}" is not a valid URI`)
+ }
+
+ if (parent.id && !parent.registryUrl) {
+ warnings.push('Parent: no registryUrl set, the default devfile registry will be used')
+ }
+
+ if (parent.version && !PARENT_VERSION_PATTERN.test(parent.version)) {
+ errors.push(`Parent: version "${parent.version}" must be "latest" or a semantic version`)
+ }
+}
+
+/**
+ * Validates the elements of a `parent` override.
+ *
+ * Overrides are merged on top of the parent content, so no value is mandatory,
+ * but the structural rules of the schema (names, mutually exclusive sources)
+ * still apply to them.
+ */
+function validateParentOverrides(parent, errors, warnings) {
+ if (!parent) return
+
+ for (const [kind, label] of PROJECT_LABELS) {
+ validateProjects(parent[kind], { kind, label: `Parent ${label.toLowerCase()}`, partial: true }, errors, warnings)
+ }
+
+ validateComponents(parent.components, errors, warnings, { partial: true, label: 'Parent component' })
+ validateCommands(parent.commands, parent.components, errors, warnings, {
+ partial: true,
+ label: 'Parent command'
+ })
+}
+
+function validateRawProject(project, where, errors) {
+ validateAttributeText(project.attributes, `${where} attributes`, errors)
+ checkMapEntries(project.git?.remotes, where, 'git remote', errors)
+}
+
+function validateRawComponent(component, where, errors) {
+ validateAttributeText(component.attributes, `${where} attributes`, errors)
+
+ const container = component.container
+ if (container) {
+ checkNamedEntries(
+ container.env,
+ where,
+ 'environment variable',
+ (entry) => str(entry?.value) !== undefined,
+ errors
+ )
+ checkNamedEntries(
+ container.volumeMounts,
+ where,
+ 'volume mount',
+ (entry) => str(entry?.path) !== undefined,
+ errors
+ )
+ checkMapEntries(container.annotation?.deployment, where, 'deployment annotation', errors)
+ checkMapEntries(container.annotation?.service, where, 'service annotation', errors)
+ }
+
+ const dockerfile = component.image?.dockerfile
+ if (dockerfile?.git) {
+ checkMapEntries(dockerfile.git.remotes, `${where} dockerfile`, 'git remote', errors)
+ }
+
+ for (const key of ENDPOINT_HOLDERS) {
+ ;(component[key]?.endpoints || []).forEach((endpoint, index) => {
+ const label = `${where} ${elementLabel('endpoint', endpoint.name, index)}`
+ validateAttributeText(endpoint.attributes, `${label} attributes`, errors)
+ checkMapEntries(endpoint.annotation, label, 'annotation', errors)
+ })
+ }
+}
+
+function validateRawCommand(command, where, errors) {
+ validateAttributeText(command.attributes, `${where} attributes`, errors)
+
+ if (command.exec) {
+ checkNamedEntries(
+ command.exec.env,
+ where,
+ 'environment variable',
+ (entry) => str(entry?.value) !== undefined,
+ errors
+ )
+ }
+}
+
+/**
+ * Validates the parts of the wizard state that serialization flattens away:
+ * free-form attribute text, and the `[{ key, value }]` maps whose duplicate or
+ * nameless entries would disappear from the generated devfile without a word.
+ */
+function validateRawState(data, errors, warnings) {
+ validateAttributeText(data.attributes, 'Devfile attributes', errors)
+ validateAttributeText(data.metadata?.attributes, 'Metadata attributes', errors)
+
+ checkMapEntries(data.variables, 'Variables', 'variable', errors)
+ for (const variable of data.variables || []) {
+ const key = str(variable?.key)
+ if (key !== undefined && !VARIABLE_KEY_PATTERN.test(key)) {
+ warnings.push(`Variables: "${key}" should only use alphanumeric characters, dashes and underscores`)
}
}
- return errors
+ for (const [kind, label] of PROJECT_LABELS) {
+ ;(data[kind] || []).forEach((project, index) => {
+ validateRawProject(project, elementLabel(label, project.name, index), errors)
+ })
+ }
+
+ ;(data.components || []).forEach((component, index) => {
+ validateRawComponent(component, elementLabel('Component', component.name, index), errors)
+ })
+
+ ;(data.commands || []).forEach((command, index) => {
+ validateRawCommand(command, elementLabel('Command', command.id, index), errors)
+ })
+
+ if (!data.parent) return
+
+ validateAttributeText(data.parent.attributes, 'Parent attributes', errors)
+ checkMapEntries(data.parent.variables, 'Parent variables', 'variable', errors)
+
+ for (const [kind, label] of PROJECT_LABELS) {
+ ;(data.parent[kind] || []).forEach((project, index) => {
+ validateRawProject(project, elementLabel(`Parent ${label.toLowerCase()}`, project.name, index), errors)
+ })
+ }
+
+ ;(data.parent.components || []).forEach((component, index) => {
+ validateRawComponent(component, elementLabel('Parent component', component.name, index), errors)
+ })
+
+ ;(data.parent.commands || []).forEach((command, index) => {
+ validateRawCommand(command, elementLabel('Parent command', command.id, index), errors)
+ })
+}
+
+/**
+ * Validates the wizard state against the devfile 2.3.0 schema and the
+ * devfile/api validation rules.
+ *
+ * Returns blocking `errors` and non-blocking `warnings`.
+ */
+export function validateDevfile(data) {
+ const errors = []
+ const warnings = []
+
+ validateRawState(data, errors, warnings)
+
+ const devfile = buildDevfile(data)
+
+ if (!devfile.schemaVersion) {
+ errors.push('Schema version is required')
+ } else if (!SCHEMA_VERSION_PATTERN.test(devfile.schemaVersion)) {
+ errors.push(`Schema version "${devfile.schemaVersion}" is not a valid devfile schema version`)
+ }
+
+ const options = { hasParent: devfile.parent !== undefined }
+
+ if (data.parent && devfile.parent === undefined) {
+ warnings.push('Parent: no parent source configured, the section is left out of the devfile')
+ }
+
+ validateMetadataSection(devfile.metadata, errors, warnings)
+ validateParentSection(devfile.parent, errors, warnings)
+ for (const [kind, label] of PROJECT_LABELS) {
+ validateProjects(devfile[kind], { kind, label }, errors, warnings)
+ }
+ validateComponents(devfile.components, errors, warnings, options)
+ validateCommands(devfile.commands, devfile.components, errors, warnings, options)
+ validateEvents(devfile.events, devfile.commands, errors, warnings, options)
+ validateParentOverrides(devfile.parent, errors, warnings)
+
+ return { errors, warnings }
}
diff --git a/src/utils/factories.js b/src/utils/factories.js
new file mode 100644
index 0000000..d71054a
--- /dev/null
+++ b/src/utils/factories.js
@@ -0,0 +1,373 @@
+import { DEVFILE_SCHEMA_VERSION } from './constants'
+
+/**
+ * Factories for every editable devfile element.
+ *
+ * Conventions used by the wizard state (see devfileGenerator.js for the
+ * matching serializer):
+ *
+ * - `null` means "this optional sub-object is not selected" (e.g. a project
+ * that uses a git source keeps `zip: null`). The serializer never emits it.
+ * - `''` means "selected but empty" and is dropped from the generated YAML.
+ * - `undefined` for a boolean means "not set" so that parent overrides can
+ * distinguish between an unset flag and an explicit `false`.
+ * - string -> string maps (git remotes, annotations, variables) are kept as
+ * ordered `[{ key, value }]` arrays so that keys stay editable.
+ * - free-form `attributes` are kept as raw YAML text and parsed on serialize.
+ * - elements of the reorderable lists carry a `_uid` so that React can keep
+ * editor state with the item and not with its position. It is wizard-only
+ * state; the serializer walks the schema and never emits it.
+ *
+ * `partial` marks elements that live under `parent` overrides, where no field
+ * is required and booleans must stay tri-state.
+ */
+
+export const COMPONENT_TYPE_KEYS = ['container', 'kubernetes', 'openshift', 'volume', 'image']
+export const COMMAND_TYPE_KEYS = ['exec', 'apply', 'composite']
+export const PROJECT_KINDS = ['projects', 'starterProjects', 'dependentProjects']
+
+let uidCounter = 0
+
+export function nextUid() {
+ uidCounter += 1
+ return `uid-${uidCounter}`
+}
+
+export function createKeyValue(key = '', value = '') {
+ return { key, value }
+}
+
+export function createEnvVar() {
+ return { name: '', value: '' }
+}
+
+export function createVolumeMount() {
+ return { name: '', path: '' }
+}
+
+export function createEndpoint() {
+ return {
+ _uid: nextUid(),
+ name: '',
+ targetPort: '',
+ exposure: '',
+ protocol: '',
+ path: '',
+ secure: false,
+ annotation: [],
+ attributes: ''
+ }
+}
+
+export function createContainer(partial = false) {
+ return {
+ image: '',
+ command: [],
+ args: [],
+ env: [],
+ volumeMounts: [],
+ endpoints: [],
+ memoryRequest: '',
+ memoryLimit: '',
+ cpuRequest: '',
+ cpuLimit: '',
+ mountSources: partial ? undefined : true,
+ sourceMapping: '',
+ dedicatedPod: partial ? undefined : false,
+ annotation: { deployment: [], service: [] }
+ }
+}
+
+export function createVolume(partial = false) {
+ return {
+ size: '',
+ ephemeral: partial ? undefined : false
+ }
+}
+
+export function createK8sLike() {
+ return {
+ uri: '',
+ inlined: null,
+ deployByDefault: undefined,
+ endpoints: []
+ }
+}
+
+export function createGitSource() {
+ return {
+ remotes: [createKeyValue('origin', '')],
+ checkoutFrom: { remote: '', revision: '' }
+ }
+}
+
+export function createDockerfile(partial = false) {
+ return {
+ uri: '',
+ devfileRegistry: null,
+ git: null,
+ buildContext: '',
+ rootRequired: partial ? undefined : false,
+ args: []
+ }
+}
+
+export function createImage(partial = false) {
+ return {
+ imageName: '',
+ autoBuild: undefined,
+ dockerfile: createDockerfile(partial)
+ }
+}
+
+/**
+ * Inside a parent override an image component either redefines the build or
+ * only flips `autoBuild`; the schema rejects setting both.
+ */
+export function getImageOverrideType(image) {
+ return image && image.dockerfile == null ? 'autoBuild' : 'dockerfile'
+}
+
+export function applyImageOverrideType(image, type) {
+ if (type === 'autoBuild') {
+ return { ...image, dockerfile: null, autoBuild: image.autoBuild ?? true }
+ }
+ return { ...image, autoBuild: undefined, dockerfile: image.dockerfile || createDockerfile(true) }
+}
+
+export function createComponent(type = 'container', partial = false) {
+ const component = { name: '', attributes: '' }
+ return applyComponentType(component, type, partial)
+}
+
+export function applyComponentType(component, type, partial = false) {
+ const next = {
+ _uid: component._uid || nextUid(),
+ name: component.name || '',
+ attributes: component.attributes || ''
+ }
+
+ switch (type) {
+ case 'volume':
+ next.volume = createVolume(partial)
+ break
+ case 'kubernetes':
+ next.kubernetes = createK8sLike()
+ break
+ case 'openshift':
+ next.openshift = createK8sLike()
+ break
+ case 'image':
+ next.image = createImage(partial)
+ break
+ case 'container':
+ default:
+ next.container = createContainer(partial)
+ break
+ }
+
+ return next
+}
+
+export function getComponentType(component) {
+ if (!component) return 'container'
+ return COMPONENT_TYPE_KEYS.find((key) => component[key] != null) || 'container'
+}
+
+export function createCommandGroup(partial = false) {
+ return { kind: '', isDefault: partial ? undefined : false }
+}
+
+export function createCommand(type = 'exec', partial = false) {
+ const command = { id: '', attributes: '' }
+ return applyCommandType(command, type, partial)
+}
+
+export function applyCommandType(command, type, partial = false) {
+ const next = {
+ _uid: command._uid || nextUid(),
+ id: command.id || '',
+ attributes: command.attributes || ''
+ }
+
+ switch (type) {
+ case 'apply':
+ next.apply = {
+ component: '',
+ label: '',
+ group: createCommandGroup(partial)
+ }
+ break
+ case 'composite':
+ next.composite = {
+ commands: [],
+ parallel: partial ? undefined : false,
+ label: '',
+ group: createCommandGroup(partial)
+ }
+ break
+ case 'exec':
+ default:
+ next.exec = {
+ component: '',
+ commandLine: '',
+ workingDir: '',
+ env: [],
+ label: '',
+ group: createCommandGroup(partial),
+ hotReloadCapable: partial ? undefined : false
+ }
+ break
+ }
+
+ return next
+}
+
+export function getCommandType(command) {
+ if (!command) return 'exec'
+ return COMMAND_TYPE_KEYS.find((key) => command[key] != null) || 'exec'
+}
+
+export function createProject(kind = 'projects') {
+ const project = { _uid: nextUid(), name: '', attributes: '' }
+
+ if (kind === 'starterProjects') {
+ project.description = ''
+ project.subDir = ''
+ } else {
+ project.clonePath = ''
+ }
+
+ project.git = createGitSource()
+ project.zip = null
+
+ return project
+}
+
+export function applyProjectSourceType(project, type) {
+ if (type === 'zip') {
+ return { ...project, git: null, zip: project.zip || { location: '' } }
+ }
+ return { ...project, zip: null, git: project.git || createGitSource() }
+}
+
+export function getProjectSourceType(project) {
+ return project && project.zip != null ? 'zip' : 'git'
+}
+
+export function getK8sSourceType(k8s) {
+ return k8s && k8s.inlined != null ? 'inlined' : 'uri'
+}
+
+export function applyK8sSourceType(k8s, type) {
+ if (type === 'inlined') {
+ return { ...k8s, uri: null, inlined: k8s.inlined ?? '' }
+ }
+ return { ...k8s, inlined: null, uri: k8s.uri ?? '' }
+}
+
+export function getDockerfileSourceType(dockerfile) {
+ if (!dockerfile) return 'uri'
+ if (dockerfile.devfileRegistry != null) return 'devfileRegistry'
+ if (dockerfile.git != null) return 'git'
+ return 'uri'
+}
+
+export function applyDockerfileSourceType(dockerfile, type) {
+ const next = { ...dockerfile, uri: null, devfileRegistry: null, git: null }
+
+ switch (type) {
+ case 'devfileRegistry':
+ next.devfileRegistry = dockerfile.devfileRegistry || { id: '', registryUrl: '' }
+ break
+ case 'git':
+ next.git = dockerfile.git || { ...createGitSource(), fileLocation: '' }
+ break
+ case 'uri':
+ default:
+ next.uri = dockerfile.uri ?? ''
+ break
+ }
+
+ return next
+}
+
+export function createParent() {
+ return {
+ uri: '',
+ id: null,
+ kubernetes: null,
+ registryUrl: '',
+ version: '',
+ variables: [],
+ attributes: '',
+ components: [],
+ commands: [],
+ projects: [],
+ starterProjects: [],
+ dependentProjects: []
+ }
+}
+
+export function getParentSourceType(parent) {
+ if (!parent) return 'uri'
+ if (parent.id != null) return 'id'
+ if (parent.kubernetes != null) return 'kubernetes'
+ return 'uri'
+}
+
+export function applyParentSourceType(parent, type) {
+ const next = { ...parent, uri: null, id: null, kubernetes: null }
+
+ switch (type) {
+ case 'id':
+ next.id = parent.id ?? ''
+ break
+ case 'kubernetes':
+ next.kubernetes = parent.kubernetes || { name: '', namespace: '' }
+ break
+ case 'uri':
+ default:
+ next.uri = parent.uri ?? ''
+ break
+ }
+
+ return next
+}
+
+export function createInitialDevfileData() {
+ return {
+ schemaVersion: DEVFILE_SCHEMA_VERSION,
+ metadata: {
+ name: '',
+ generateName: '',
+ version: '',
+ displayName: '',
+ description: '',
+ icon: '',
+ language: '',
+ projectType: '',
+ provider: '',
+ tags: [],
+ architectures: [],
+ website: '',
+ supportUrl: '',
+ globalMemoryLimit: '',
+ attributes: ''
+ },
+ attributes: '',
+ parent: null,
+ projects: [],
+ starterProjects: [],
+ dependentProjects: [],
+ components: [],
+ commands: [],
+ events: {
+ preStart: [],
+ postStart: [],
+ preStop: [],
+ postStop: []
+ },
+ variables: []
+ }
+}
diff --git a/src/utils/validation.js b/src/utils/validation.js
deleted file mode 100644
index 10e7561..0000000
--- a/src/utils/validation.js
+++ /dev/null
@@ -1,152 +0,0 @@
-export const validators = {
- required: (value) => {
- if (!value || (typeof value === 'string' && value.trim() === '')) {
- return 'This field is required'
- }
- return null
- },
-
- devfileName: (value) => {
- if (!value) {
- return 'Name is required'
- }
-
- const pattern = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
- if (!pattern.test(value)) {
- return 'Name must be lowercase alphanumeric with hyphens only'
- }
-
- if (value.length > 63) {
- return 'Name must be 63 characters or less'
- }
-
- return null
- },
-
- url: (value) => {
- if (!value) return null // URL is optional
-
- try {
- new URL(value)
- return null
- } catch {
- return 'Must be a valid URL (e.g., https://example.com)'
- }
- },
-
- uniqueId: (value, existingIds = []) => {
- if (!value) {
- return 'ID is required'
- }
-
- if (existingIds.includes(value)) {
- return 'ID must be unique'
- }
-
- return null
- },
-
- alphanumeric: (value) => {
- if (!value) return null
-
- const pattern = /^[a-zA-Z0-9_-]+$/
- if (!pattern.test(value)) {
- return 'Must contain only letters, numbers, underscores, and hyphens'
- }
-
- return null
- }
-}
-
-export function validateMetadata(metadata) {
- const errors = {}
-
- const nameError = validators.devfileName(metadata.name)
- if (nameError) errors.name = nameError
-
- if (metadata.website) {
- const websiteError = validators.url(metadata.website)
- if (websiteError) errors.website = websiteError
- }
-
- if (metadata.supportUrl) {
- const supportUrlError = validators.url(metadata.supportUrl)
- if (supportUrlError) errors.supportUrl = supportUrlError
- }
-
- return errors
-}
-
-export function validateProject(project, existingNames = []) {
- const errors = {}
-
- if (!project.name) {
- errors.name = 'Project name is required'
- } else if (existingNames.includes(project.name)) {
- errors.name = 'Project name must be unique'
- }
-
- const hasGit = project.git && project.git.remotes && project.git.remotes.origin
- const hasZip = project.zip && project.zip.location
-
- if (!hasGit && !hasZip) {
- errors.source = 'Either Git or Zip source is required'
- }
-
- if (hasGit) {
- const urlError = validators.url(project.git.remotes.origin)
- if (urlError) errors.gitUrl = urlError
- }
-
- if (hasZip) {
- const urlError = validators.url(project.zip.location)
- if (urlError) errors.zipLocation = urlError
- }
-
- return errors
-}
-
-export function validateComponent(component, existingNames = []) {
- const errors = {}
-
- if (!component.name) {
- errors.name = 'Component name is required'
- } else if (existingNames.includes(component.name)) {
- errors.name = 'Component name must be unique'
- }
-
- if (component.container && !component.container.image) {
- errors.image = 'Container image is required'
- }
-
- if (component.volume && !component.volume.size) {
- errors.size = 'Volume size is required'
- }
-
- return errors
-}
-
-export function validateCommand(command, existingIds = []) {
- const errors = {}
-
- if (!command.id) {
- errors.id = 'Command ID is required'
- } else if (existingIds.includes(command.id)) {
- errors.id = 'Command ID must be unique'
- }
-
- if (command.exec) {
- if (!command.exec.commandLine) {
- errors.commandLine = 'Command line is required'
- }
- if (!command.exec.component) {
- errors.component = 'Component reference is required'
- }
- }
-
- if (command.composite && (!command.composite.commands || command.composite.commands.length === 0)) {
- errors.commands = 'At least one command reference is required'
- }
-
- return errors
-}
diff --git a/tests/README.md b/tests/README.md
index 91faf9c..598d052 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -1,6 +1,11 @@
# Devfile GUI Wizard - Tests
-This directory contains end-to-end tests for the Devfile GUI Wizard using Playwright.
+The suite runs on [Playwright](https://playwright.dev/) and has two projects:
+
+| Project | Directory | What it covers |
+|---|---|---|
+| `unit` | `tests/unit` | The devfile serializer and validator, and compliance with the devfile 2.3.0 JSON schema. Runs in node, no browser needed. |
+| `chromium` | `tests/e2e` | The wizard itself, driven through the browser. |
## Running Tests
@@ -11,7 +16,7 @@ This directory contains end-to-end tests for the Devfile GUI Wizard using Playwr
npm install
```
-2. Install Playwright browsers (first time only):
+2. Install Playwright browsers (first time only, for the e2e project):
```bash
npx playwright install
```
@@ -19,86 +24,85 @@ This directory contains end-to-end tests for the Devfile GUI Wizard using Playwr
### Run Tests
```bash
-# Run all tests in headless mode
+# Everything
npm test
-# Run tests in UI mode (interactive)
-npm run test:ui
+# Serializer, validator and schema compliance only
+npm run test:unit
-# Run tests in debug mode
-npm run test:debug
+# Browser tests only
+npm run test:e2e
-# Run specific test file
-npx playwright test quarkus-devfile.spec.js
+# Interactive UI mode
+npm run test:ui
-# Run tests in headed mode (see the browser)
-npx playwright test --headed
+# A single file
+npx playwright test tests/unit/devfile-validation.spec.js
```
## Test Files
-### `quarkus-devfile.spec.js`
+### `tests/unit/schema-coverage.spec.js`
-Tests the generation of a Quarkus-like devfile through the wizard interface.
+Checks the wizard against the published devfile 2.3.0 JSON schema, vendored in
+`tests/schema/devfile-2.3.0.json` so the suite stays offline and pinned:
-**Test 1: Quarkus-like devfile generation**
-- Creates a simplified version of the [Quarkus API Example devfile](https://github.com/che-incubator/quarkus-api-example/blob/main/devfile.yaml)
-- Verifies:
- - Schema version is 2.3.0
- - Metadata is correctly set
- - Components (tools container, postgresql container, m2 volume)
- - Commands (package, startdev)
- - Events (postStart: package)
- - Schema compliance (no `type` field in components, no `kind` field in commands)
+- every devfile the wizard can generate is accepted by the schema (validated with ajv),
+- **every field of the schema can be generated** — the test walks the schema and fails when one of
+ its fields never appears in the generated devfiles,
+- the wizard never emits a field the schema does not define,
+- a devfile rejected by the wizard's own validator is also rejected by the schema.
-**Test 2: Schema compliance**
-- Verifies that minimal devfiles comply with Devfile 2.3.0 specification
-- Ensures no deprecated fields are present in the generated YAML
+The maximal devfiles it uses live in `tests/unit/fixtures/devfile-states.js`. When the wizard gains
+support for a new field, add it there.
-## Current Limitations
+### `tests/unit/devfile-generator.spec.js`
-The test generates a simplified version of the Quarkus devfile because the following features are not yet implemented in the UI:
+Serialization rules: blank values are dropped, strings trimmed, maps built from the wizard's
+key/value lists, booleans that default to `false` only emitted when `true`, tri-state booleans kept
+inside parent overrides, free-form `attributes` parsed from YAML, and the YAML output parsing back
+into the same object.
-### Not Yet Supported:
-- `generateName` (uses `name` instead)
-- Component features:
- - Environment variables (`env`)
- - Endpoints
- - Volume mounts (`volumeMounts`)
- - Resource requests (`memoryRequest`, `cpuRequest`)
-- Command features:
- - Labels
- - Groups (`group.kind`, `group.isDefault`)
-- Root-level attributes
+### `tests/unit/devfile-validation.spec.js`
-These features will be added in future enhancements to match the full Quarkus devfile specification.
+One test per validation rule: metadata, projects, components, commands, events, parent and parent
+overrides, including which problems are errors and which are only warnings.
-## Test Output
+### `tests/e2e/quarkus-devfile.spec.js`
-When tests run successfully, you'll see:
-- ✅ Test passed indicators
-- Generated YAML output in the console
-- Detailed test reports in `playwright-report/` directory
+Rebuilds the [Quarkus API Example devfile](https://github.com/che-incubator/quarkus-api-example/blob/main/devfile.yaml)
+through the UI — `generateName`, devfile attributes, container env, endpoints, volume mounts,
+resource requests and limits, command labels and groups, and the `postStart` event — then compares
+the generated YAML with the expected devfile. A second test downloads the file and parses it.
-## Debugging Tests
+### `tests/e2e/full-coverage.spec.js`
-To debug a failing test:
+Walks the sections the Quarkus example does not use: the parent step, the three project lists, all
+five component types, all three command types and variables. It also covers the validation
+feedback: errors are shown on their step and disable the download, warnings do not.
-1. Run in debug mode:
- ```bash
- npm run test:debug
- ```
+## Updating the schema
-2. Or use the Playwright Inspector:
- ```bash
- npx playwright test --debug
- ```
+The vendored schema comes from the devfile project:
-3. View the test report:
- ```bash
- npx playwright show-report
- ```
+```bash
+curl -o tests/schema/devfile-2.3.0.json https://devfile.io/devfile-schemas/2.3.0.json
+```
+
+## Debugging Tests
+
+```bash
+# Playwright inspector
+npm run test:debug
+
+# Watch the browser
+npx playwright test --project=chromium --headed
+
+# Open the last report
+npx playwright show-report
+```
-## CI/CD
+## CI
-Tests are configured to run in CI environments. The configuration in `playwright.config.js` automatically detects CI environments and adjusts behavior accordingly (e.g., retries, parallel execution).
+`.github/workflows/test.yaml` builds the app and runs the whole suite on every push to `main` and
+on every pull request, and uploads the Playwright report as an artifact.
diff --git a/tests/e2e/full-coverage.spec.js b/tests/e2e/full-coverage.spec.js
new file mode 100644
index 0000000..a6d4b54
--- /dev/null
+++ b/tests/e2e/full-coverage.spec.js
@@ -0,0 +1,280 @@
+import { expect, test } from '@playwright/test'
+import { goToStep, readYaml } from './helpers'
+
+/** Exercises the sections the Quarkus example does not use. */
+test.describe('full schema coverage through the UI', () => {
+ test('parent, projects, every component and command type, and variables', async ({ page }) => {
+ await page.goto('/')
+
+ await page.fill('input[name="name"]', 'full-coverage')
+ await page.fill('input[name="version"]', '1.0.0')
+ await page.fill('input[name="language"]', 'Java')
+ await page.getByRole('button', { name: 'Add Tag' }).click()
+ await page.fill('input[name="metadata-tags-0"]', 'Quarkus')
+ await page.check('input[name="metadata-architectures-amd64"]')
+
+ // Parent
+ await goToStep(page, 'Parent')
+ await page.getByRole('button', { name: 'Add Parent' }).click()
+ await page.selectOption('select[name="parent-source"]', 'id')
+ await page.fill('input[name="parent-id"]', 'java-quarkus')
+ await page.fill('input[name="parent-registryUrl"]', 'https://registry.devfile.io')
+ await page.fill('input[name="parent-version"]', 'latest')
+
+ // Projects, starter projects and dependent projects
+ await goToStep(page, 'Projects')
+ // The "Projects" section is the one open by default.
+ await page.getByRole('button', { name: 'Add Project' }).click()
+ await page.fill('input[name="project-name-0"]', 'sample')
+ await page.fill('input[name="project-clonePath-0"]', 'sample')
+ await page.fill('input[name="project-git-0-remotes-value-0"]', 'https://github.com/devfile/api.git')
+ await page.fill('input[name="project-git-0-checkoutFrom-revision"]', 'main')
+
+ await page.getByRole('button', { name: /Starter Projects/ }).click()
+ await page.getByRole('button', { name: 'Add Starter Project' }).click()
+ await page.fill('input[name="starter-project-name-0"]', 'starter')
+ await page.fill('input[name="starter-project-description-0"]', 'A starter project')
+ await page.selectOption('select[name="starter-project-source-0"]', 'zip')
+ await page.fill('input[name="starter-project-zip-location-0"]', 'https://example.com/starter.zip')
+
+ await page.getByRole('button', { name: /Dependent Projects/ }).click()
+ await page.getByRole('button', { name: 'Add Dependent Project' }).click()
+ await page.fill('input[name="dependent-project-name-0"]', 'library')
+ await page.fill('input[name="dependent-project-git-0-remotes-value-0"]', 'https://github.com/devfile/library.git')
+
+ // Components: container, volume, kubernetes, openshift and image
+ await goToStep(page, 'Components')
+
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ await page.fill('input[name="component-name-0"]', 'tools')
+ await page.fill('input[name="component-image-0"]', 'quay.io/devfile/universal-developer-image:ubi9-latest')
+
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ await page.fill('input[name="component-name-1"]', 'storage')
+ await page.selectOption('select[name="component-type-1"]', 'volume')
+ await page.fill('input[name="component-size-1"]', '2Gi')
+ await page.check('input[name="component-ephemeral-1"]')
+
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ await page.fill('input[name="component-name-2"]', 'k8s-resource')
+ await page.selectOption('select[name="component-type-2"]', 'kubernetes')
+ await page.fill('input[name="component-kubernetes-uri-2"]', 'manifests/deployment.yaml')
+ await page.selectOption('select[name="component-kubernetes-deployByDefault-2"]', 'true')
+
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ await page.fill('input[name="component-name-3"]', 'ocp-resource')
+ await page.selectOption('select[name="component-type-3"]', 'openshift')
+ await page.selectOption('select[name="component-openshift-source-3"]', 'inlined')
+ await page.fill(
+ 'textarea[name="component-openshift-inlined-3"]',
+ 'apiVersion: v1\nkind: Service\nmetadata:\n name: my-service'
+ )
+
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ await page.fill('input[name="component-name-4"]', 'app-image')
+ await page.selectOption('select[name="component-type-4"]', 'image')
+ await page.fill('input[name="component-imageName-4"]', 'quay.io/my-org/my-app:latest')
+ await page.fill('input[name="component-dockerfile-uri-4"]', 'docker/Dockerfile')
+ await page.fill('input[name="component-dockerfile-buildContext-4"]', '.')
+
+ // Commands: exec, apply and composite
+ await goToStep(page, 'Commands')
+
+ await page.getByRole('button', { name: 'Add Command' }).click()
+ await page.fill('input[name="command-id-0"]', 'build')
+ await page.fill('input[name="command-component-0"]', 'tools')
+ await page.fill('input[name="command-commandLine-0"]', './mvnw package')
+
+ await page.getByRole('button', { name: 'Add Command' }).click()
+ await page.fill('input[name="command-id-1"]', 'deploy')
+ await page.selectOption('select[name="command-type-1"]', 'apply')
+ await page.fill('input[name="command-component-1"]', 'k8s-resource')
+
+ await page.getByRole('button', { name: 'Add Command' }).click()
+ await page.fill('input[name="command-id-2"]', 'build-and-deploy')
+ await page.selectOption('select[name="command-type-2"]', 'composite')
+ await page.getByRole('button', { name: 'Add Sub-command' }).click()
+ await page.fill('input[name="command-subcommand-2-0"]', 'build')
+
+ // Events
+ await goToStep(page, 'Events')
+ await page.check('input[name="preStart-deploy"]')
+
+ // Variables
+ await goToStep(page, 'Variables')
+ await page.getByRole('button', { name: 'Add Variable' }).click()
+ await page.fill('input[name="variable-key-0"]', 'VERSION')
+ await page.fill('input[name="variable-value-0"]', '1.0.0')
+
+ await goToStep(page, 'Review & Download')
+
+ const devfile = await readYaml(page)
+
+ expect(devfile.metadata).toEqual({
+ name: 'full-coverage',
+ version: '1.0.0',
+ language: 'Java',
+ tags: ['Quarkus'],
+ architectures: ['amd64']
+ })
+ expect(devfile.parent).toEqual({
+ id: 'java-quarkus',
+ registryUrl: 'https://registry.devfile.io',
+ version: 'latest'
+ })
+ expect(devfile.projects).toEqual([
+ {
+ name: 'sample',
+ clonePath: 'sample',
+ git: {
+ remotes: { origin: 'https://github.com/devfile/api.git' },
+ checkoutFrom: { revision: 'main' }
+ }
+ }
+ ])
+ expect(devfile.starterProjects).toEqual([
+ { name: 'starter', description: 'A starter project', zip: { location: 'https://example.com/starter.zip' } }
+ ])
+ expect(devfile.dependentProjects).toEqual([
+ { name: 'library', git: { remotes: { origin: 'https://github.com/devfile/library.git' } } }
+ ])
+ expect(devfile.components.map((component) => Object.keys(component).at(-1))).toEqual([
+ 'container',
+ 'volume',
+ 'kubernetes',
+ 'openshift',
+ 'image'
+ ])
+ expect(devfile.components[1].volume).toEqual({ size: '2Gi', ephemeral: true })
+ expect(devfile.components[2].kubernetes).toEqual({
+ uri: 'manifests/deployment.yaml',
+ deployByDefault: true
+ })
+ expect(devfile.components[3].openshift.inlined).toContain('kind: Service')
+ expect(devfile.components[4].image).toEqual({
+ imageName: 'quay.io/my-org/my-app:latest',
+ dockerfile: { uri: 'docker/Dockerfile', buildContext: '.' }
+ })
+ expect(devfile.commands.map((command) => Object.keys(command).at(-1))).toEqual([
+ 'exec',
+ 'apply',
+ 'composite'
+ ])
+ expect(devfile.commands[2].composite).toEqual({ commands: ['build'] })
+ expect(devfile.events).toEqual({ preStart: ['deploy'] })
+ expect(devfile.variables).toEqual({ VERSION: '1.0.0' })
+
+ await expect(page.getByText('Valid devfile')).toBeVisible()
+ })
+
+ test('a parent can be removed again', async ({ page }) => {
+ await page.goto('/')
+ await page.fill('input[name="name"]', 'sample')
+
+ await goToStep(page, 'Parent')
+ await page.getByRole('button', { name: 'Add Parent' }).click()
+ await page.fill('input[name="parent-uri"]', 'https://example.com/devfile.yaml')
+ expect((await readYaml(page)).parent).toEqual({ uri: 'https://example.com/devfile.yaml' })
+
+ await page.getByRole('button', { name: 'Remove Parent' }).click()
+ await expect(page.getByRole('button', { name: 'Add Parent' })).toBeVisible()
+ expect((await readYaml(page)).parent).toBeUndefined()
+ })
+})
+
+test.describe('validation', () => {
+ test('errors are reported on the step and block the download', async ({ page }) => {
+ await page.goto('/')
+ await page.fill('input[name="name"]', 'sample')
+
+ await goToStep(page, 'Components')
+ await page.getByRole('button', { name: 'Add Component' }).click()
+
+ const errors = page.getByTestId('step-errors')
+ await expect(errors).toContainText('name is required')
+ await expect(errors).toContainText('container image is required')
+
+ await goToStep(page, 'Review & Download')
+ await expect(page.getByRole('button', { name: 'Download devfile.yaml' })).toBeDisabled()
+
+ await page.getByRole('button', { name: 'Edit' }).nth(3).click()
+ await page.fill('input[name="component-name-0"]', 'tools')
+ await page.fill('input[name="component-image-0"]', 'quay.io/devfile/base-developer-image:ubi10-latest')
+
+ await goToStep(page, 'Review & Download')
+ await expect(page.getByRole('button', { name: 'Download devfile.yaml' })).toBeEnabled()
+ })
+
+ test('warnings do not block the download', async ({ page }) => {
+ await page.goto('/')
+ await page.fill('input[name="name"]', 'sample')
+
+ await goToStep(page, 'Projects')
+ await page.getByRole('button', { name: 'Add Project' }).click()
+ await page.fill('input[name="project-name-0"]', 'sample')
+ await page.selectOption('select[name="project-source-0"]', 'zip')
+
+ await expect(page.getByTestId('step-warnings')).toContainText('zip source has no location')
+
+ await goToStep(page, 'Review & Download')
+ await expect(page.getByTestId('review-warnings')).toContainText('zip source has no location')
+ await expect(page.getByRole('button', { name: 'Download devfile.yaml' })).toBeEnabled()
+ })
+
+ test('a map entry that would be dropped is reported on its step', async ({ page }) => {
+ await page.goto('/')
+ await page.fill('input[name="name"]', 'sample')
+
+ await goToStep(page, 'Variables')
+ await page.getByRole('button', { name: 'Add Variable' }).click()
+ await page.fill('input[name="variable-value-0"]', '1.0.0')
+
+ const errors = page.getByTestId('step-errors')
+ await expect(errors).toContainText('a variable has a value but no name')
+
+ await page.fill('input[name="variable-key-0"]', 'VERSION')
+ await expect(errors).toBeHidden()
+ expect((await readYaml(page)).variables).toEqual({ VERSION: '1.0.0' })
+
+ await page.getByRole('button', { name: 'Add Variable' }).click()
+ await page.fill('input[name="variable-key-1"]', 'VERSION')
+ await expect(errors).toContainText('duplicate variable "VERSION"')
+ })
+
+ test('the wizard can be reset', async ({ page }) => {
+ await page.goto('/')
+ await page.fill('input[name="name"]', 'sample')
+ await goToStep(page, 'Review & Download')
+
+ page.on('dialog', (dialog) => dialog.accept())
+ await page.getByRole('button', { name: 'Reset Wizard' }).click()
+
+ await expect(page.getByRole('heading', { name: 'Basic Information' })).toBeVisible()
+ await expect(page.locator('input[name="name"]')).toHaveValue('')
+ })
+})
+
+test.describe('reordering a list', () => {
+ const envSection = (page, testId) =>
+ page.getByTestId(testId).getByRole('button', { name: /Environment variables/ })
+
+ test('an expanded section follows its component', async ({ page }) => {
+ await page.goto('/')
+ await page.fill('input[name="name"]', 'sample')
+
+ await goToStep(page, 'Components')
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ await page.fill('input[name="component-name-0"]', 'first')
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ await page.fill('input[name="component-name-1"]', 'second')
+
+ await envSection(page, 'component-1').click()
+ await expect(envSection(page, 'component-1')).toHaveAttribute('aria-expanded', 'true')
+
+ await page.getByTestId('component-1').getByTitle('Move up').click()
+
+ await expect(page.locator('input[name="component-name-0"]')).toHaveValue('second')
+ await expect(envSection(page, 'component-0')).toHaveAttribute('aria-expanded', 'true')
+ await expect(envSection(page, 'component-1')).toHaveAttribute('aria-expanded', 'false')
+ })
+})
diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js
new file mode 100644
index 0000000..39d0491
--- /dev/null
+++ b/tests/e2e/helpers.js
@@ -0,0 +1,24 @@
+import { expect } from '@playwright/test'
+import yaml from 'js-yaml'
+
+/** Clicks through the wizard until the step with the given heading is shown. */
+export async function goToStep(page, heading) {
+ for (let step = 0; step < 10; step += 1) {
+ const current = page.locator('.wizard-step h2')
+ const text = await current.textContent()
+ if (text === heading) return
+
+ await page.getByRole('button', { name: 'Next', exact: true }).click()
+ await expect(current).not.toHaveText(text)
+ }
+
+ throw new Error(`Step "${heading}" was never reached`)
+}
+
+/** Parses the devfile shown in the YAML preview. */
+export async function readYaml(page) {
+ const preview = page.getByTestId('yaml-preview').first()
+ await expect(preview).toBeVisible()
+
+ return yaml.load(await preview.textContent())
+}
diff --git a/tests/e2e/quarkus-devfile.spec.js b/tests/e2e/quarkus-devfile.spec.js
new file mode 100644
index 0000000..661a69f
--- /dev/null
+++ b/tests/e2e/quarkus-devfile.spec.js
@@ -0,0 +1,226 @@
+import { expect, test } from '@playwright/test'
+import yaml from 'js-yaml'
+import { goToStep, readYaml } from './helpers'
+
+/**
+ * Rebuilds the Quarkus API example devfile through the wizard.
+ *
+ * Reference devfile:
+ * https://github.com/che-incubator/quarkus-api-example/blob/main/devfile.yaml
+ */
+test.describe('Quarkus API example', () => {
+ test('is reproduced through the wizard', async ({ page }) => {
+ await page.goto('/')
+
+ // Basics: the example uses generateName and a storage-type attribute.
+ await expect(page.getByRole('heading', { name: 'Basic Information' })).toBeVisible()
+ await page.fill('input[name="generateName"]', 'quarkus-api-example')
+ await page.getByRole('button', { name: /Devfile attributes/ }).click()
+ await page.fill('textarea[name="devfile-attributes"]', 'controller.devfile.io/storage-type: ephemeral')
+
+ await goToStep(page, 'Components')
+
+ // tools container
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ const tools = page.getByTestId('component-0')
+ await tools.locator('input[name="component-name-0"]').fill('tools')
+ await tools.locator('input[name="component-image-0"]').fill(
+ 'quay.io/devfile/universal-developer-image:ubi9-latest'
+ )
+ await tools.locator('input[name="component-memoryRequest-0"]').fill('512Mi')
+ await tools.locator('input[name="component-memoryLimit-0"]').fill('6G')
+ await tools.locator('input[name="component-cpuRequest-0"]').fill('1000m')
+ await tools.locator('input[name="component-cpuLimit-0"]').fill('4000m')
+
+ await tools.getByRole('button', { name: /Environment variables/ }).click()
+ await tools.getByRole('button', { name: 'Add Environment Variable' }).click()
+ await tools.locator('input[name="component-env-0-name-0"]').fill('QUARKUS_HTTP_HOST')
+ await tools.locator('input[name="component-env-0-value-0"]').fill('0.0.0.0')
+
+ await tools.getByRole('button', { name: /^Endpoints/ }).click()
+ await tools.getByRole('button', { name: 'Add Endpoint' }).click()
+ await tools.locator('input[name="component-endpoint-0-name-0"]').fill('debug')
+ await tools.locator('input[name="component-endpoint-0-targetPort-0"]').fill('5005')
+ await tools.locator('select[name="component-endpoint-0-protocol-0"]').selectOption('tcp')
+ await tools.locator('select[name="component-endpoint-0-exposure-0"]').selectOption('none')
+
+ await tools.getByRole('button', { name: 'Add Endpoint' }).click()
+ await tools.locator('input[name="component-endpoint-0-name-1"]').fill('list-all-food')
+ await tools.locator('input[name="component-endpoint-0-targetPort-1"]').fill('8080')
+ await tools.locator('select[name="component-endpoint-0-protocol-1"]').selectOption('https')
+ await tools.locator('select[name="component-endpoint-0-exposure-1"]').selectOption('public')
+ await tools.locator('input[name="component-endpoint-0-path-1"]').fill('/food')
+
+ await tools.getByRole('button', { name: /Volume mounts/ }).click()
+ await tools.getByRole('button', { name: 'Add Volume Mount' }).click()
+ await tools.locator('input[name="component-volumeMount-0-name-0"]').fill('m2')
+ await tools.locator('input[name="component-volumeMount-0-path-0"]').fill('/home/user/.m2')
+
+ // postgresql container
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ const postgres = page.getByTestId('component-1')
+ await postgres.locator('input[name="component-name-1"]').fill('postgresql')
+ await postgres.locator('input[name="component-image-1"]').fill(
+ 'quay.io/centos7/postgresql-13-centos7@sha256:994f5c622e2913bda1c4a7fa3b0c7e7f75e7caa3ac66ff1ed70ccfe65c40dd75'
+ )
+ await postgres.getByRole('button', { name: /Environment variables/ }).click()
+ const postgresEnv = [
+ ['POSTGRESQL_USER', 'user'],
+ ['POSTGRESQL_PASSWORD', 'password'],
+ ['POSTGRESQL_DATABASE', 'food_db'],
+ ['PGDATA', '/tmp/pgdata']
+ ]
+ for (const [index, [name, value]] of postgresEnv.entries()) {
+ await postgres.getByRole('button', { name: 'Add Environment Variable' }).click()
+ await postgres.locator(`input[name="component-env-1-name-${index}"]`).fill(name)
+ await postgres.locator(`input[name="component-env-1-value-${index}"]`).fill(value)
+ }
+
+ // m2 volume
+ await page.getByRole('button', { name: 'Add Component' }).click()
+ const m2 = page.getByTestId('component-2')
+ await m2.locator('input[name="component-name-2"]').fill('m2')
+ await m2.locator('select[name="component-type-2"]').selectOption('volume')
+ await m2.locator('input[name="component-size-2"]').fill('1G')
+
+ await goToStep(page, 'Commands')
+
+ const execCommands = [
+ {
+ id: 'package',
+ label: 'Package',
+ commandLine: './mvnw clean package -DskipTests=true',
+ group: 'build',
+ isDefault: true
+ },
+ {
+ id: 'startdev',
+ label: 'Start Development mode (Hot reload + debug)',
+ commandLine: './mvnw compile quarkus:dev',
+ group: 'run',
+ isDefault: true
+ },
+ { id: 'runtests', label: 'Run Tests', commandLine: './mvnw test', group: 'test', isDefault: false }
+ ]
+
+ for (const [index, command] of execCommands.entries()) {
+ await page.getByRole('button', { name: 'Add Command' }).click()
+ const item = page.getByTestId(`command-${index}`)
+ await item.locator(`input[name="command-id-${index}"]`).fill(command.id)
+ await item.locator(`input[name="command-component-${index}"]`).fill('tools')
+ await item.locator(`input[name="command-commandLine-${index}"]`).fill(command.commandLine)
+ await item
+ .locator(`input[name="command-workingDir-${index}"]`)
+ .fill('${PROJECTS_ROOT}/quarkus-api-example')
+ await item.locator(`input[name="command-label-${index}"]`).fill(command.label)
+ await item.locator(`select[name="command-group-kind-${index}"]`).selectOption(command.group)
+ if (command.isDefault) {
+ await item.locator(`input[name="command-group-isDefault-${index}"]`).check()
+ }
+ }
+
+ await goToStep(page, 'Events')
+ await page.locator('input[name="postStart-package"]').check()
+
+ await goToStep(page, 'Review & Download')
+
+ const devfile = await readYaml(page)
+
+ expect(devfile).toEqual({
+ schemaVersion: '2.3.0',
+ metadata: { generateName: 'quarkus-api-example' },
+ attributes: { 'controller.devfile.io/storage-type': 'ephemeral' },
+ components: [
+ {
+ name: 'tools',
+ container: {
+ image: 'quay.io/devfile/universal-developer-image:ubi9-latest',
+ env: [{ name: 'QUARKUS_HTTP_HOST', value: '0.0.0.0' }],
+ volumeMounts: [{ name: 'm2', path: '/home/user/.m2' }],
+ endpoints: [
+ { name: 'debug', targetPort: 5005, exposure: 'none', protocol: 'tcp' },
+ { name: 'list-all-food', targetPort: 8080, exposure: 'public', protocol: 'https', path: '/food' }
+ ],
+ memoryRequest: '512Mi',
+ memoryLimit: '6G',
+ cpuRequest: '1000m',
+ cpuLimit: '4000m',
+ mountSources: true
+ }
+ },
+ {
+ name: 'postgresql',
+ container: {
+ image:
+ 'quay.io/centos7/postgresql-13-centos7@sha256:994f5c622e2913bda1c4a7fa3b0c7e7f75e7caa3ac66ff1ed70ccfe65c40dd75',
+ env: [
+ { name: 'POSTGRESQL_USER', value: 'user' },
+ { name: 'POSTGRESQL_PASSWORD', value: 'password' },
+ { name: 'POSTGRESQL_DATABASE', value: 'food_db' },
+ { name: 'PGDATA', value: '/tmp/pgdata' }
+ ],
+ mountSources: true
+ }
+ },
+ { name: 'm2', volume: { size: '1G' } }
+ ],
+ commands: [
+ {
+ id: 'package',
+ exec: {
+ commandLine: './mvnw clean package -DskipTests=true',
+ component: 'tools',
+ workingDir: '${PROJECTS_ROOT}/quarkus-api-example',
+ label: 'Package',
+ group: { kind: 'build', isDefault: true }
+ }
+ },
+ {
+ id: 'startdev',
+ exec: {
+ commandLine: './mvnw compile quarkus:dev',
+ component: 'tools',
+ workingDir: '${PROJECTS_ROOT}/quarkus-api-example',
+ label: 'Start Development mode (Hot reload + debug)',
+ group: { kind: 'run', isDefault: true }
+ }
+ },
+ {
+ id: 'runtests',
+ exec: {
+ commandLine: './mvnw test',
+ component: 'tools',
+ workingDir: '${PROJECTS_ROOT}/quarkus-api-example',
+ label: 'Run Tests',
+ group: { kind: 'test' }
+ }
+ }
+ ],
+ events: { postStart: ['package'] }
+ })
+
+ await expect(page.getByText('Valid devfile')).toBeVisible()
+ })
+
+ test('downloads the generated devfile', async ({ page }) => {
+ await page.goto('/')
+ await page.fill('input[name="name"]', 'quarkus-api-example')
+ await goToStep(page, 'Review & Download')
+
+ const [download] = await Promise.all([
+ page.waitForEvent('download'),
+ page.getByRole('button', { name: 'Download devfile.yaml' }).click()
+ ])
+
+ expect(download.suggestedFilename()).toBe('quarkus-api-example-devfile.yaml')
+
+ const stream = await download.createReadStream()
+ const chunks = []
+ for await (const chunk of stream) chunks.push(chunk)
+
+ expect(yaml.load(Buffer.concat(chunks).toString('utf8'))).toEqual({
+ schemaVersion: '2.3.0',
+ metadata: { name: 'quarkus-api-example' }
+ })
+ })
+})
diff --git a/tests/quarkus-devfile.spec.js b/tests/quarkus-devfile.spec.js
deleted file mode 100644
index f1611cf..0000000
--- a/tests/quarkus-devfile.spec.js
+++ /dev/null
@@ -1,209 +0,0 @@
-import { test, expect } from '@playwright/test'
-import yaml from 'js-yaml'
-
-/**
- * This test generates a simplified version of the Quarkus API example devfile
- * using the GUI wizard.
- *
- * Original devfile: https://github.com/che-incubator/quarkus-api-example/blob/main/devfile.yaml
- *
- * NOTE: This test uses 'name' instead of 'generateName' and doesn't include:
- * - Component env variables, endpoints, volumeMounts, memoryRequest, cpuRequest
- * - Command labels and groups (kind, isDefault)
- * - Root-level attributes
- *
- * These features will be added to the UI in future enhancements.
- */
-test.describe('Quarkus API Example Devfile Generation', () => {
- test('should generate a basic Quarkus-like devfile through the wizard', async ({ page }) => {
- // Navigate to the wizard
- await page.goto('/')
-
- // Wait for the wizard to load
- await expect(page.locator('h2:has-text("Basic Information")')).toBeVisible()
-
- // Step 1: Metadata
- // NOTE: Using 'name' field since 'generateName' is not yet implemented in UI
- await page.fill('input[name="name"]', 'quarkus-api-example')
-
- // Navigate to next step
- await page.click('button:has-text("Next")')
-
- // Step 2: Projects - skip
- await expect(page.locator('h2:has-text("Projects")')).toBeVisible()
- await page.click('button:has-text("Next")')
-
- // Step 3: Components
- await expect(page.locator('h2:has-text("Components")')).toBeVisible()
-
- // Component 1: tools container
- await page.click('button:has-text("Add Component")')
- await page.fill('input[name="component-name-0"]', 'tools')
- await page.selectOption('select[name="component-type-0"]', 'container')
- await page.fill('input[name="component-image-0"]', 'quay.io/devfile/universal-developer-image:ubi9-latest')
- await page.fill('input[name="component-memoryLimit-0"]', '6G')
- await page.fill('input[name="component-cpuLimit-0"]', '4000m')
-
- // Component 2: postgresql container
- await page.click('button:has-text("Add Component")')
- await page.fill('input[name="component-name-1"]', 'postgresql')
- await page.selectOption('select[name="component-type-1"]', 'container')
- await page.fill('input[name="component-image-1"]', 'quay.io/centos7/postgresql-13-centos7@sha256:994f5c622e2913bda1c4a7fa3b0c7e7f75e7caa3ac66ff1ed70ccfe65c40dd75')
-
- // Component 3: m2 volume
- await page.click('button:has-text("Add Component")')
- await page.fill('input[name="component-name-2"]', 'm2')
- await page.selectOption('select[name="component-type-2"]', 'volume')
- // Wait for the volume size field to appear after type change
- await expect(page.locator('input[name="component-size-2"]')).toBeVisible()
- await page.fill('input[name="component-size-2"]', '1G')
-
- // Navigate to next step
- await page.click('button:has-text("Next")')
-
- // Step 4: Commands
- await expect(page.locator('h2:has-text("Commands")')).toBeVisible()
-
- // Command 1: package (build command)
- await page.click('button:has-text("Add Command")')
- await page.fill('input[name="command-id-0"]', 'package')
- await page.selectOption('select[name="command-type-0"]', 'exec')
- await page.selectOption('select[name="command-component-0"]', 'tools')
- await page.fill('input[name="command-commandLine-0"]', './mvnw clean package -DskipTests=true')
- await page.fill('input[name="command-workingDir-0"]', '${PROJECTS_ROOT}/quarkus-api-example')
-
- // Command 2: startdev (run command)
- await page.click('button:has-text("Add Command")')
- await page.fill('input[name="command-id-1"]', 'startdev')
- await page.selectOption('select[name="command-type-1"]', 'exec')
- await page.selectOption('select[name="command-component-1"]', 'tools')
- await page.fill('input[name="command-commandLine-1"]', './mvnw compile quarkus:dev')
- await page.fill('input[name="command-workingDir-1"]', '${PROJECTS_ROOT}/quarkus-api-example')
-
- // Navigate to next step
- await page.click('button:has-text("Next")')
-
- // Step 5: Events
- await expect(page.locator('h2:has-text("Events")')).toBeVisible()
-
- // Select package command for postStart event
- // Find all checkboxes labeled "package" and select the one under "Post Start" (index 1)
- await page.locator('label:has-text("package") input[type="checkbox"]').nth(1).check()
-
- // Navigate to next step
- await page.click('button:has-text("Next")')
-
- // Step 6: Variables - skip
- await expect(page.locator('h2:has-text("Variables")')).toBeVisible()
- await page.click('button:has-text("Next")')
-
- // Step 7: Review & Download
- await expect(page.locator('h2:has-text("Review & Download")')).toBeVisible()
-
- // Wait for YAML preview to be visible
- const yamlPreview = page.locator('pre, code').first()
- await expect(yamlPreview).toBeVisible()
-
- // Get the generated YAML content from the preview
- const yamlContent = await yamlPreview.textContent()
-
- // Parse the YAML
- const generatedDevfile = yaml.load(yamlContent)
-
- // Verify basic structure
- expect(generatedDevfile.schemaVersion).toBe('2.3.0')
- expect(generatedDevfile.metadata.name).toBe('quarkus-api-example')
-
- // Verify components
- expect(generatedDevfile.components.length).toBeGreaterThanOrEqual(3)
-
- const toolsComponent = generatedDevfile.components.find(c => c.name === 'tools')
- expect(toolsComponent).toBeDefined()
- expect(toolsComponent.container).toBeDefined()
- expect(toolsComponent.container.image).toBe('quay.io/devfile/universal-developer-image:ubi9-latest')
- expect(toolsComponent.container.memoryLimit).toBe('6G')
- expect(toolsComponent.container.cpuLimit).toBe('4000m')
-
- const postgresComponent = generatedDevfile.components.find(c => c.name === 'postgresql')
- expect(postgresComponent).toBeDefined()
- expect(postgresComponent.container).toBeDefined()
- expect(postgresComponent.container.image).toContain('postgresql-13-centos7')
-
- const m2Component = generatedDevfile.components.find(c => c.name === 'm2')
- expect(m2Component).toBeDefined()
- expect(m2Component.volume).toBeDefined()
- expect(m2Component.volume.size).toBe('1G')
-
- // Verify commands
- expect(generatedDevfile.commands.length).toBeGreaterThanOrEqual(2)
-
- const packageCmd = generatedDevfile.commands.find(c => c.id === 'package')
- expect(packageCmd).toBeDefined()
- expect(packageCmd.exec).toBeDefined()
- expect(packageCmd.exec.component).toBe('tools')
- expect(packageCmd.exec.commandLine).toContain('./mvnw clean package')
- expect(packageCmd.exec.workingDir).toBe('${PROJECTS_ROOT}/quarkus-api-example')
-
- const startdevCmd = generatedDevfile.commands.find(c => c.id === 'startdev')
- expect(startdevCmd).toBeDefined()
- expect(startdevCmd.exec).toBeDefined()
- expect(startdevCmd.exec.commandLine).toContain('quarkus:dev')
-
- // Verify events
- expect(generatedDevfile.events).toBeDefined()
- expect(generatedDevfile.events.postStart).toBeDefined()
- expect(generatedDevfile.events.postStart).toContain('package')
-
- // Verify schema compliance: no 'type' field in components
- generatedDevfile.components.forEach(component => {
- expect(component.type).toBeUndefined()
- })
-
- // Verify schema compliance: no 'kind' field in commands
- generatedDevfile.commands.forEach(command => {
- expect(command.kind).toBeUndefined()
- })
-
- console.log('✅ Quarkus-like devfile generated successfully!')
- console.log('\nGenerated YAML:')
- console.log(yamlContent)
- })
-
- test('should verify devfile schema 2.3.0 compliance', async ({ page }) => {
- // This test verifies that the generated devfile is compliant with schema 2.3.0
- await page.goto('/')
-
- // Fill minimal data
- await page.fill('input[name="name"]', 'test-devfile')
- await page.click('button:has-text("Next")') // Skip projects
- await page.click('button:has-text("Next")') // Skip components
- await page.click('button:has-text("Next")') // Skip commands
- await page.click('button:has-text("Next")') // Skip events
- await page.click('button:has-text("Next")') // Skip variables
- await page.click('button:has-text("Next")') // Go to review
-
- // Get generated YAML
- const yamlPreview = page.locator('pre, code').first()
- await expect(yamlPreview).toBeVisible()
- const yamlContent = await yamlPreview.textContent()
- const devfile = yaml.load(yamlContent)
-
- // Verify required fields
- expect(devfile.schemaVersion).toBe('2.3.0')
- expect(devfile.metadata).toBeDefined()
- expect(devfile.metadata.name).toBe('test-devfile')
-
- // Verify that type/kind fields are not present
- if (devfile.components) {
- devfile.components.forEach(component => {
- expect(component.type).toBeUndefined()
- })
- }
-
- if (devfile.commands) {
- devfile.commands.forEach(command => {
- expect(command.kind).toBeUndefined()
- })
- }
- })
-})
diff --git a/tests/schema/devfile-2.3.0.json b/tests/schema/devfile-2.3.0.json
new file mode 100644
index 0000000..1b482f2
--- /dev/null
+++ b/tests/schema/devfile-2.3.0.json
@@ -0,0 +1,2211 @@
+{
+ "description": "Devfile describes the structure of a cloud-native devworkspace and development environment.",
+ "type": "object",
+ "title": "Devfile schema - Version 2.3.0",
+ "required": [
+ "schemaVersion"
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "commands": {
+ "description": "Predefined, ready-to-use, devworkspace-related commands",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "exec"
+ ]
+ },
+ {
+ "required": [
+ "apply"
+ ]
+ },
+ {
+ "required": [
+ "composite"
+ ]
+ }
+ ],
+ "properties": {
+ "apply": {
+ "description": "Command that consists in applying a given component definition, typically bound to a devworkspace event.\n\nFor example, when an `apply` command is bound to a `preStart` event, and references a `container` component, it will start the container as a K8S initContainer in the devworkspace POD, unless the component has its `dedicatedPod` field set to `true`.\n\nWhen no `apply` command exist for a given component, it is assumed the component will be applied at devworkspace start by default, unless `deployByDefault` for that component is set to false.",
+ "type": "object",
+ "required": [
+ "component"
+ ],
+ "properties": {
+ "component": {
+ "description": "Describes component that will be applied",
+ "type": "string"
+ },
+ "group": {
+ "description": "Defines the group this command is part of",
+ "type": "object",
+ "required": [
+ "kind"
+ ],
+ "properties": {
+ "isDefault": {
+ "description": "Identifies the default command for a given group kind",
+ "type": "boolean"
+ },
+ "kind": {
+ "description": "Kind of group the command is part of",
+ "type": "string",
+ "enum": [
+ "build",
+ "run",
+ "test",
+ "debug",
+ "deploy"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "label": {
+ "description": "Optional label that provides a label for this command to be used in Editor UI menus for example",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "composite": {
+ "description": "Composite command that allows executing several sub-commands either sequentially or concurrently",
+ "type": "object",
+ "properties": {
+ "commands": {
+ "description": "The commands that comprise this composite command",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "group": {
+ "description": "Defines the group this command is part of",
+ "type": "object",
+ "required": [
+ "kind"
+ ],
+ "properties": {
+ "isDefault": {
+ "description": "Identifies the default command for a given group kind",
+ "type": "boolean"
+ },
+ "kind": {
+ "description": "Kind of group the command is part of",
+ "type": "string",
+ "enum": [
+ "build",
+ "run",
+ "test",
+ "debug",
+ "deploy"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "label": {
+ "description": "Optional label that provides a label for this command to be used in Editor UI menus for example",
+ "type": "string"
+ },
+ "parallel": {
+ "description": "Indicates if the sub-commands should be executed concurrently",
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false
+ },
+ "exec": {
+ "description": "CLI Command executed in an existing component container",
+ "type": "object",
+ "required": [
+ "commandLine",
+ "component"
+ ],
+ "properties": {
+ "commandLine": {
+ "description": "The actual command-line string\n\nSpecial variables that can be used:\n\n - `$PROJECTS_ROOT`: A path where projects sources are mounted as defined by container component's sourceMapping.\n\n - `$PROJECT_SOURCE`: A path to a project source ($PROJECTS_ROOT/\u003cproject-name\u003e). If there are multiple projects, this will point to the directory of the first one.",
+ "type": "string"
+ },
+ "component": {
+ "description": "Describes component to which given action relates",
+ "type": "string"
+ },
+ "env": {
+ "description": "Optional list of environment variables that have to be set before running the command",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "value"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "group": {
+ "description": "Defines the group this command is part of",
+ "type": "object",
+ "required": [
+ "kind"
+ ],
+ "properties": {
+ "isDefault": {
+ "description": "Identifies the default command for a given group kind",
+ "type": "boolean"
+ },
+ "kind": {
+ "description": "Kind of group the command is part of",
+ "type": "string",
+ "enum": [
+ "build",
+ "run",
+ "test",
+ "debug",
+ "deploy"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "hotReloadCapable": {
+ "description": "Specify whether the command is restarted or not when the source code changes. If set to `true` the command won't be restarted. A *hotReloadCapable* `run` or `debug` command is expected to handle file changes on its own and won't be restarted. A *hotReloadCapable* `build` command is expected to be executed only once and won't be executed again. This field is taken into account only for commands `build`, `run` and `debug` with `isDefault` set to `true`.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "label": {
+ "description": "Optional label that provides a label for this command to be used in Editor UI menus for example",
+ "type": "string"
+ },
+ "workingDir": {
+ "description": "Working directory where the command should be executed\n\nSpecial variables that can be used:\n\n - `$PROJECTS_ROOT`: A path where projects sources are mounted as defined by container component's sourceMapping.\n\n - `$PROJECT_SOURCE`: A path to a project source ($PROJECTS_ROOT/\u003cproject-name\u003e). If there are multiple projects, this will point to the directory of the first one.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "id": {
+ "description": "Mandatory identifier that allows referencing this command in composite commands, from a parent, or in events.",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "components": {
+ "description": "List of the devworkspace components, such as editor and plugins, user-provided containers, or other types of components",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "container"
+ ]
+ },
+ {
+ "required": [
+ "kubernetes"
+ ]
+ },
+ {
+ "required": [
+ "openshift"
+ ]
+ },
+ {
+ "required": [
+ "volume"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "container": {
+ "description": "Allows adding and configuring devworkspace-related containers",
+ "type": "object",
+ "required": [
+ "image"
+ ],
+ "properties": {
+ "annotation": {
+ "description": "Annotations that should be added to specific resources for this container",
+ "type": "object",
+ "properties": {
+ "deployment": {
+ "description": "Annotations to be added to deployment",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "service": {
+ "description": "Annotations to be added to service",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "args": {
+ "description": "The arguments to supply to the command running the dockerimage component. The arguments are supplied either to the default command provided in the image or to the overridden command.\n\nDefaults to an empty array, meaning use whatever is defined in the image.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "description": "The command to run in the dockerimage component instead of the default one provided in the image.\n\nDefaults to an empty array, meaning use whatever is defined in the image.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "cpuLimit": {
+ "type": "string"
+ },
+ "cpuRequest": {
+ "type": "string"
+ },
+ "dedicatedPod": {
+ "description": "Specify if a container should run in its own separated pod, instead of running as part of the main development environment pod.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "targetPort"
+ ],
+ "properties": {
+ "annotation": {
+ "description": "Annotations to be added to Kubernetes Ingress or Openshift Route",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant string-based free-form attributes.\n\nExamples of Che-specific attributes:\n- cookiesAuthEnabled: \"true\" / \"false\",\n- type: \"terminal\" / \"ide\" / \"ide-dev\",",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "exposure": {
+ "description": "Describes how the endpoint should be exposed on the network.\n- `public` means that the endpoint will be exposed on the public network, typically through a K8S ingress or an OpenShift route.\n- `internal` means that the endpoint will be exposed internally outside of the main devworkspace POD, typically by K8S services, to be consumed by other elements running on the same cloud internal network.\n- `none` means that the endpoint will not be exposed and will only be accessible inside the main devworkspace POD, on a local address.\n\nDefault value is `public`",
+ "type": "string",
+ "default": "public",
+ "enum": [
+ "public",
+ "internal",
+ "none"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 15,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "Path of the endpoint URL",
+ "type": "string"
+ },
+ "protocol": {
+ "description": "Describes the application and transport protocols of the traffic that will go through this endpoint.\n- `http`: Endpoint will have `http` traffic, typically on a TCP connection. It will be automaticaly promoted to `https` when the `secure` field is set to `true`.\n- `https`: Endpoint will have `https` traffic, typically on a TCP connection.\n- `ws`: Endpoint will have `ws` traffic, typically on a TCP connection. It will be automaticaly promoted to `wss` when the `secure` field is set to `true`.\n- `wss`: Endpoint will have `wss` traffic, typically on a TCP connection.\n- `tcp`: Endpoint will have traffic on a TCP connection, without specifying an application protocol.\n- `udp`: Endpoint will have traffic on an UDP connection, without specifying an application protocol.\n\nDefault value is `http`",
+ "type": "string",
+ "default": "http",
+ "enum": [
+ "http",
+ "https",
+ "ws",
+ "wss",
+ "tcp",
+ "udp"
+ ]
+ },
+ "secure": {
+ "description": "Describes whether the endpoint should be secured and protected by some authentication process. This requires a protocol of `https` or `wss`.",
+ "type": "boolean"
+ },
+ "targetPort": {
+ "description": "Port number to be used within the container component. The same port cannot be used by two different container components.",
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "env": {
+ "description": "Environment variables used in this container.\n\nThe following variables are reserved and cannot be overridden via env:\n\n - `$PROJECTS_ROOT`\n\n - `$PROJECT_SOURCE`",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "value"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "image": {
+ "type": "string"
+ },
+ "memoryLimit": {
+ "type": "string"
+ },
+ "memoryRequest": {
+ "type": "string"
+ },
+ "mountSources": {
+ "description": "Toggles whether or not the project source code should be mounted in the component.\n\nDefaults to true for all component types except plugins and components that set `dedicatedPod` to true.",
+ "type": "boolean"
+ },
+ "sourceMapping": {
+ "description": "Optional specification of the path in the container where project sources should be transferred/mounted when `mountSources` is `true`. When omitted, the default value of /projects is used.",
+ "type": "string",
+ "default": "/projects"
+ },
+ "volumeMounts": {
+ "description": "List of volumes mounts that should be mounted is this container.",
+ "type": "array",
+ "items": {
+ "description": "Volume that should be mounted to a component container",
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "description": "The volume mount name is the name of an existing `Volume` component. If several containers mount the same volume name then they will reuse the same volume and will be able to access to the same files.",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "The path in the component container where the volume should be mounted. If not path is mentioned, default path is the is `/\u003cname\u003e`.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "image": {
+ "description": "Allows specifying the definition of an image for outer loop builds",
+ "type": "object",
+ "required": [
+ "imageName"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "dockerfile"
+ ]
+ }
+ ],
+ "properties": {
+ "autoBuild": {
+ "description": "Defines if the image should be built during startup.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "dockerfile": {
+ "description": "Allows specifying dockerfile type build",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "uri"
+ ]
+ },
+ {
+ "required": [
+ "devfileRegistry"
+ ]
+ },
+ {
+ "required": [
+ "git"
+ ]
+ }
+ ],
+ "properties": {
+ "args": {
+ "description": "The arguments to supply to the dockerfile build.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "buildContext": {
+ "description": "Path of source directory to establish build context. Defaults to ${PROJECT_SOURCE} in the container",
+ "type": "string"
+ },
+ "devfileRegistry": {
+ "description": "Dockerfile's Devfile Registry source",
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "properties": {
+ "id": {
+ "description": "Id in a devfile registry that contains a Dockerfile. The src in the OCI registry required for the Dockerfile build will be downloaded for building the image.",
+ "type": "string"
+ },
+ "registryUrl": {
+ "description": "Devfile Registry URL to pull the Dockerfile from when using the Devfile Registry as Dockerfile src. To ensure the Dockerfile gets resolved consistently in different environments, it is recommended to always specify the `devfileRegistryUrl` when `Id` is used.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "git": {
+ "description": "Dockerfile's Git source",
+ "type": "object",
+ "required": [
+ "remotes"
+ ],
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "fileLocation": {
+ "description": "Location of the Dockerfile in the Git repository when using git as Dockerfile src. Defaults to Dockerfile.",
+ "type": "string"
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "rootRequired": {
+ "description": "Specify if a privileged builder pod is required.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "uri": {
+ "description": "URI Reference of a Dockerfile. It can be a full URL or a relative URI from the current devfile as the base URI.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "imageName": {
+ "description": "Name of the image for the resulting outerloop build",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "kubernetes": {
+ "description": "Allows importing into the devworkspace the Kubernetes resources defined in a given manifest. For example this allows reusing the Kubernetes definitions used to deploy some runtime components in production.",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "uri"
+ ]
+ },
+ {
+ "required": [
+ "inlined"
+ ]
+ }
+ ],
+ "properties": {
+ "deployByDefault": {
+ "description": "Defines if the component should be deployed during startup.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "targetPort"
+ ],
+ "properties": {
+ "annotation": {
+ "description": "Annotations to be added to Kubernetes Ingress or Openshift Route",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant string-based free-form attributes.\n\nExamples of Che-specific attributes:\n- cookiesAuthEnabled: \"true\" / \"false\",\n- type: \"terminal\" / \"ide\" / \"ide-dev\",",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "exposure": {
+ "description": "Describes how the endpoint should be exposed on the network.\n- `public` means that the endpoint will be exposed on the public network, typically through a K8S ingress or an OpenShift route.\n- `internal` means that the endpoint will be exposed internally outside of the main devworkspace POD, typically by K8S services, to be consumed by other elements running on the same cloud internal network.\n- `none` means that the endpoint will not be exposed and will only be accessible inside the main devworkspace POD, on a local address.\n\nDefault value is `public`",
+ "type": "string",
+ "default": "public",
+ "enum": [
+ "public",
+ "internal",
+ "none"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 15,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "Path of the endpoint URL",
+ "type": "string"
+ },
+ "protocol": {
+ "description": "Describes the application and transport protocols of the traffic that will go through this endpoint.\n- `http`: Endpoint will have `http` traffic, typically on a TCP connection. It will be automaticaly promoted to `https` when the `secure` field is set to `true`.\n- `https`: Endpoint will have `https` traffic, typically on a TCP connection.\n- `ws`: Endpoint will have `ws` traffic, typically on a TCP connection. It will be automaticaly promoted to `wss` when the `secure` field is set to `true`.\n- `wss`: Endpoint will have `wss` traffic, typically on a TCP connection.\n- `tcp`: Endpoint will have traffic on a TCP connection, without specifying an application protocol.\n- `udp`: Endpoint will have traffic on an UDP connection, without specifying an application protocol.\n\nDefault value is `http`",
+ "type": "string",
+ "default": "http",
+ "enum": [
+ "http",
+ "https",
+ "ws",
+ "wss",
+ "tcp",
+ "udp"
+ ]
+ },
+ "secure": {
+ "description": "Describes whether the endpoint should be secured and protected by some authentication process. This requires a protocol of `https` or `wss`.",
+ "type": "boolean"
+ },
+ "targetPort": {
+ "description": "Port number to be used within the container component. The same port cannot be used by two different container components.",
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "inlined": {
+ "description": "Inlined manifest",
+ "type": "string"
+ },
+ "uri": {
+ "description": "Location in a file fetched from a uri.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Mandatory name that allows referencing the component from other elements (such as commands) or from an external devfile that may reference this component through a parent or a plugin.",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "openshift": {
+ "description": "Allows importing into the devworkspace the OpenShift resources defined in a given manifest. For example this allows reusing the OpenShift definitions used to deploy some runtime components in production.",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "uri"
+ ]
+ },
+ {
+ "required": [
+ "inlined"
+ ]
+ }
+ ],
+ "properties": {
+ "deployByDefault": {
+ "description": "Defines if the component should be deployed during startup.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "targetPort"
+ ],
+ "properties": {
+ "annotation": {
+ "description": "Annotations to be added to Kubernetes Ingress or Openshift Route",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant string-based free-form attributes.\n\nExamples of Che-specific attributes:\n- cookiesAuthEnabled: \"true\" / \"false\",\n- type: \"terminal\" / \"ide\" / \"ide-dev\",",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "exposure": {
+ "description": "Describes how the endpoint should be exposed on the network.\n- `public` means that the endpoint will be exposed on the public network, typically through a K8S ingress or an OpenShift route.\n- `internal` means that the endpoint will be exposed internally outside of the main devworkspace POD, typically by K8S services, to be consumed by other elements running on the same cloud internal network.\n- `none` means that the endpoint will not be exposed and will only be accessible inside the main devworkspace POD, on a local address.\n\nDefault value is `public`",
+ "type": "string",
+ "default": "public",
+ "enum": [
+ "public",
+ "internal",
+ "none"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 15,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "Path of the endpoint URL",
+ "type": "string"
+ },
+ "protocol": {
+ "description": "Describes the application and transport protocols of the traffic that will go through this endpoint.\n- `http`: Endpoint will have `http` traffic, typically on a TCP connection. It will be automaticaly promoted to `https` when the `secure` field is set to `true`.\n- `https`: Endpoint will have `https` traffic, typically on a TCP connection.\n- `ws`: Endpoint will have `ws` traffic, typically on a TCP connection. It will be automaticaly promoted to `wss` when the `secure` field is set to `true`.\n- `wss`: Endpoint will have `wss` traffic, typically on a TCP connection.\n- `tcp`: Endpoint will have traffic on a TCP connection, without specifying an application protocol.\n- `udp`: Endpoint will have traffic on an UDP connection, without specifying an application protocol.\n\nDefault value is `http`",
+ "type": "string",
+ "default": "http",
+ "enum": [
+ "http",
+ "https",
+ "ws",
+ "wss",
+ "tcp",
+ "udp"
+ ]
+ },
+ "secure": {
+ "description": "Describes whether the endpoint should be secured and protected by some authentication process. This requires a protocol of `https` or `wss`.",
+ "type": "boolean"
+ },
+ "targetPort": {
+ "description": "Port number to be used within the container component. The same port cannot be used by two different container components.",
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "inlined": {
+ "description": "Inlined manifest",
+ "type": "string"
+ },
+ "uri": {
+ "description": "Location in a file fetched from a uri.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "volume": {
+ "description": "Allows specifying the definition of a volume shared by several other components",
+ "type": "object",
+ "properties": {
+ "ephemeral": {
+ "description": "Ephemeral volumes are not stored persistently across restarts. Defaults to false",
+ "type": "boolean"
+ },
+ "size": {
+ "description": "Size of the volume",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "dependentProjects": {
+ "description": "Additional projects related to the main project in the devfile, contianing names and sources locations",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "git"
+ ]
+ },
+ {
+ "required": [
+ "zip"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "clonePath": {
+ "description": "Path relative to the root of the projects to which this project should be cloned into. This is a unix-style relative path (i.e. uses forward slashes). The path is invalid if it is absolute or tries to escape the project root through the usage of '..'. If not specified, defaults to the project name.",
+ "type": "string"
+ },
+ "git": {
+ "description": "Project's Git source",
+ "type": "object",
+ "required": [
+ "remotes"
+ ],
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Project name",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "zip": {
+ "description": "Project's Zip source",
+ "type": "object",
+ "properties": {
+ "location": {
+ "description": "Zip project's source location address. Should be file path of the archive, e.g. file://$FILE_PATH",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "events": {
+ "description": "Bindings of commands to events. Each command is referred-to by its name.",
+ "type": "object",
+ "properties": {
+ "postStart": {
+ "description": "IDs of commands that should be executed after the devworkspace is completely started. In the case of Che-Theia, these commands should be executed after all plugins and extensions have started, including project cloning. This means that those commands are not triggered until the user opens the IDE in his browser.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "postStop": {
+ "description": "IDs of commands that should be executed after stopping the devworkspace.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "preStart": {
+ "description": "IDs of commands that should be executed before the devworkspace start. Kubernetes-wise, these commands would typically be executed in init containers of the devworkspace POD.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "preStop": {
+ "description": "IDs of commands that should be executed before stopping the devworkspace.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "metadata": {
+ "description": "Optional metadata",
+ "type": "object",
+ "properties": {
+ "architectures": {
+ "description": "Optional list of processor architectures that the devfile supports, empty list suggests that the devfile can be used on any architecture",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "description": "Architecture describes the architecture type",
+ "type": "string",
+ "enum": [
+ "amd64",
+ "arm64",
+ "ppc64le",
+ "s390x"
+ ]
+ }
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes. Deprecated, use the top-level attributes field instead.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "description": {
+ "description": "Optional devfile description",
+ "type": "string"
+ },
+ "displayName": {
+ "description": "Optional devfile display name",
+ "type": "string"
+ },
+ "globalMemoryLimit": {
+ "description": "Optional devfile global memory limit",
+ "type": "string"
+ },
+ "icon": {
+ "description": "Optional devfile icon, can be a URI or a relative path in the project",
+ "type": "string"
+ },
+ "language": {
+ "description": "Optional devfile language",
+ "type": "string"
+ },
+ "name": {
+ "description": "Optional devfile name",
+ "type": "string"
+ },
+ "projectType": {
+ "description": "Optional devfile project type",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Optional devfile provider information",
+ "type": "string"
+ },
+ "supportUrl": {
+ "description": "Optional link to a page that provides support information",
+ "type": "string"
+ },
+ "tags": {
+ "description": "Optional devfile tags",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "version": {
+ "description": "Optional semver-compatible version",
+ "type": "string",
+ "pattern": "^([0-9]+)\\.([0-9]+)\\.([0-9]+)(\\-[0-9a-z-]+(\\.[0-9a-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$"
+ },
+ "website": {
+ "description": "Optional devfile website",
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ },
+ "parent": {
+ "description": "Parent devworkspace template",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "uri"
+ ]
+ },
+ {
+ "required": [
+ "id"
+ ]
+ },
+ {
+ "required": [
+ "kubernetes"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Overrides of attributes encapsulated in a parent devfile. Overriding is done according to K8S strategic merge patch standard rules.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "commands": {
+ "description": "Overrides of commands encapsulated in a parent devfile or a plugin. Overriding is done according to K8S strategic merge patch standard rules.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "exec"
+ ]
+ },
+ {
+ "required": [
+ "apply"
+ ]
+ },
+ {
+ "required": [
+ "composite"
+ ]
+ }
+ ],
+ "properties": {
+ "apply": {
+ "description": "Command that consists in applying a given component definition, typically bound to a devworkspace event.\n\nFor example, when an `apply` command is bound to a `preStart` event, and references a `container` component, it will start the container as a K8S initContainer in the devworkspace POD, unless the component has its `dedicatedPod` field set to `true`.\n\nWhen no `apply` command exist for a given component, it is assumed the component will be applied at devworkspace start by default, unless `deployByDefault` for that component is set to false.",
+ "type": "object",
+ "properties": {
+ "component": {
+ "description": "Describes component that will be applied",
+ "type": "string"
+ },
+ "group": {
+ "description": "Defines the group this command is part of",
+ "type": "object",
+ "properties": {
+ "isDefault": {
+ "description": "Identifies the default command for a given group kind",
+ "type": "boolean"
+ },
+ "kind": {
+ "description": "Kind of group the command is part of",
+ "type": "string",
+ "enum": [
+ "build",
+ "run",
+ "test",
+ "debug",
+ "deploy"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "label": {
+ "description": "Optional label that provides a label for this command to be used in Editor UI menus for example",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "composite": {
+ "description": "Composite command that allows executing several sub-commands either sequentially or concurrently",
+ "type": "object",
+ "properties": {
+ "commands": {
+ "description": "The commands that comprise this composite command",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "group": {
+ "description": "Defines the group this command is part of",
+ "type": "object",
+ "properties": {
+ "isDefault": {
+ "description": "Identifies the default command for a given group kind",
+ "type": "boolean"
+ },
+ "kind": {
+ "description": "Kind of group the command is part of",
+ "type": "string",
+ "enum": [
+ "build",
+ "run",
+ "test",
+ "debug",
+ "deploy"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "label": {
+ "description": "Optional label that provides a label for this command to be used in Editor UI menus for example",
+ "type": "string"
+ },
+ "parallel": {
+ "description": "Indicates if the sub-commands should be executed concurrently",
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false
+ },
+ "exec": {
+ "description": "CLI Command executed in an existing component container",
+ "type": "object",
+ "properties": {
+ "commandLine": {
+ "description": "The actual command-line string\n\nSpecial variables that can be used:\n\n - `$PROJECTS_ROOT`: A path where projects sources are mounted as defined by container component's sourceMapping.\n\n - `$PROJECT_SOURCE`: A path to a project source ($PROJECTS_ROOT/\u003cproject-name\u003e). If there are multiple projects, this will point to the directory of the first one.",
+ "type": "string"
+ },
+ "component": {
+ "description": "Describes component to which given action relates",
+ "type": "string"
+ },
+ "env": {
+ "description": "Optional list of environment variables that have to be set before running the command",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "group": {
+ "description": "Defines the group this command is part of",
+ "type": "object",
+ "properties": {
+ "isDefault": {
+ "description": "Identifies the default command for a given group kind",
+ "type": "boolean"
+ },
+ "kind": {
+ "description": "Kind of group the command is part of",
+ "type": "string",
+ "enum": [
+ "build",
+ "run",
+ "test",
+ "debug",
+ "deploy"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "hotReloadCapable": {
+ "description": "Specify whether the command is restarted or not when the source code changes. If set to `true` the command won't be restarted. A *hotReloadCapable* `run` or `debug` command is expected to handle file changes on its own and won't be restarted. A *hotReloadCapable* `build` command is expected to be executed only once and won't be executed again. This field is taken into account only for commands `build`, `run` and `debug` with `isDefault` set to `true`.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "label": {
+ "description": "Optional label that provides a label for this command to be used in Editor UI menus for example",
+ "type": "string"
+ },
+ "workingDir": {
+ "description": "Working directory where the command should be executed\n\nSpecial variables that can be used:\n\n - `$PROJECTS_ROOT`: A path where projects sources are mounted as defined by container component's sourceMapping.\n\n - `$PROJECT_SOURCE`: A path to a project source ($PROJECTS_ROOT/\u003cproject-name\u003e). If there are multiple projects, this will point to the directory of the first one.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "id": {
+ "description": "Mandatory identifier that allows referencing this command in composite commands, from a parent, or in events.",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "components": {
+ "description": "Overrides of components encapsulated in a parent devfile or a plugin. Overriding is done according to K8S strategic merge patch standard rules.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "container"
+ ]
+ },
+ {
+ "required": [
+ "kubernetes"
+ ]
+ },
+ {
+ "required": [
+ "openshift"
+ ]
+ },
+ {
+ "required": [
+ "volume"
+ ]
+ },
+ {
+ "required": [
+ "image"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "container": {
+ "description": "Allows adding and configuring devworkspace-related containers",
+ "type": "object",
+ "properties": {
+ "annotation": {
+ "description": "Annotations that should be added to specific resources for this container",
+ "type": "object",
+ "properties": {
+ "deployment": {
+ "description": "Annotations to be added to deployment",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "service": {
+ "description": "Annotations to be added to service",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "args": {
+ "description": "The arguments to supply to the command running the dockerimage component. The arguments are supplied either to the default command provided in the image or to the overridden command.\n\nDefaults to an empty array, meaning use whatever is defined in the image.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "description": "The command to run in the dockerimage component instead of the default one provided in the image.\n\nDefaults to an empty array, meaning use whatever is defined in the image.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "cpuLimit": {
+ "type": "string"
+ },
+ "cpuRequest": {
+ "type": "string"
+ },
+ "dedicatedPod": {
+ "description": "Specify if a container should run in its own separated pod, instead of running as part of the main development environment pod.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "annotation": {
+ "description": "Annotations to be added to Kubernetes Ingress or Openshift Route",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant string-based free-form attributes.\n\nExamples of Che-specific attributes:\n- cookiesAuthEnabled: \"true\" / \"false\",\n- type: \"terminal\" / \"ide\" / \"ide-dev\",",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "exposure": {
+ "description": "Describes how the endpoint should be exposed on the network.\n- `public` means that the endpoint will be exposed on the public network, typically through a K8S ingress or an OpenShift route.\n- `internal` means that the endpoint will be exposed internally outside of the main devworkspace POD, typically by K8S services, to be consumed by other elements running on the same cloud internal network.\n- `none` means that the endpoint will not be exposed and will only be accessible inside the main devworkspace POD, on a local address.\n\nDefault value is `public`",
+ "type": "string",
+ "enum": [
+ "public",
+ "internal",
+ "none"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 15,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "Path of the endpoint URL",
+ "type": "string"
+ },
+ "protocol": {
+ "description": "Describes the application and transport protocols of the traffic that will go through this endpoint.\n- `http`: Endpoint will have `http` traffic, typically on a TCP connection. It will be automaticaly promoted to `https` when the `secure` field is set to `true`.\n- `https`: Endpoint will have `https` traffic, typically on a TCP connection.\n- `ws`: Endpoint will have `ws` traffic, typically on a TCP connection. It will be automaticaly promoted to `wss` when the `secure` field is set to `true`.\n- `wss`: Endpoint will have `wss` traffic, typically on a TCP connection.\n- `tcp`: Endpoint will have traffic on a TCP connection, without specifying an application protocol.\n- `udp`: Endpoint will have traffic on an UDP connection, without specifying an application protocol.\n\nDefault value is `http`",
+ "type": "string",
+ "enum": [
+ "http",
+ "https",
+ "ws",
+ "wss",
+ "tcp",
+ "udp"
+ ]
+ },
+ "secure": {
+ "description": "Describes whether the endpoint should be secured and protected by some authentication process. This requires a protocol of `https` or `wss`.",
+ "type": "boolean"
+ },
+ "targetPort": {
+ "description": "Port number to be used within the container component. The same port cannot be used by two different container components.",
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "env": {
+ "description": "Environment variables used in this container.\n\nThe following variables are reserved and cannot be overridden via env:\n\n - `$PROJECTS_ROOT`\n\n - `$PROJECT_SOURCE`",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "image": {
+ "type": "string"
+ },
+ "memoryLimit": {
+ "type": "string"
+ },
+ "memoryRequest": {
+ "type": "string"
+ },
+ "mountSources": {
+ "description": "Toggles whether or not the project source code should be mounted in the component.\n\nDefaults to true for all component types except plugins and components that set `dedicatedPod` to true.",
+ "type": "boolean"
+ },
+ "sourceMapping": {
+ "description": "Optional specification of the path in the container where project sources should be transferred/mounted when `mountSources` is `true`. When omitted, the default value of /projects is used.",
+ "type": "string"
+ },
+ "volumeMounts": {
+ "description": "List of volumes mounts that should be mounted is this container.",
+ "type": "array",
+ "items": {
+ "description": "Volume that should be mounted to a component container",
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "description": "The volume mount name is the name of an existing `Volume` component. If several containers mount the same volume name then they will reuse the same volume and will be able to access to the same files.",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "The path in the component container where the volume should be mounted. If not path is mentioned, default path is the is `/\u003cname\u003e`.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "image": {
+ "description": "Allows specifying the definition of an image for outer loop builds",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "dockerfile"
+ ]
+ },
+ {
+ "required": [
+ "autoBuild"
+ ]
+ }
+ ],
+ "properties": {
+ "autoBuild": {
+ "description": "Defines if the image should be built during startup.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "dockerfile": {
+ "description": "Allows specifying dockerfile type build",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "uri"
+ ]
+ },
+ {
+ "required": [
+ "devfileRegistry"
+ ]
+ },
+ {
+ "required": [
+ "git"
+ ]
+ }
+ ],
+ "properties": {
+ "args": {
+ "description": "The arguments to supply to the dockerfile build.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "buildContext": {
+ "description": "Path of source directory to establish build context. Defaults to ${PROJECT_SOURCE} in the container",
+ "type": "string"
+ },
+ "devfileRegistry": {
+ "description": "Dockerfile's Devfile Registry source",
+ "type": "object",
+ "properties": {
+ "id": {
+ "description": "Id in a devfile registry that contains a Dockerfile. The src in the OCI registry required for the Dockerfile build will be downloaded for building the image.",
+ "type": "string"
+ },
+ "registryUrl": {
+ "description": "Devfile Registry URL to pull the Dockerfile from when using the Devfile Registry as Dockerfile src. To ensure the Dockerfile gets resolved consistently in different environments, it is recommended to always specify the `devfileRegistryUrl` when `Id` is used.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "git": {
+ "description": "Dockerfile's Git source",
+ "type": "object",
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "fileLocation": {
+ "description": "Location of the Dockerfile in the Git repository when using git as Dockerfile src. Defaults to Dockerfile.",
+ "type": "string"
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "rootRequired": {
+ "description": "Specify if a privileged builder pod is required.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "uri": {
+ "description": "URI Reference of a Dockerfile. It can be a full URL or a relative URI from the current devfile as the base URI.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "imageName": {
+ "description": "Name of the image for the resulting outerloop build",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "kubernetes": {
+ "description": "Allows importing into the devworkspace the Kubernetes resources defined in a given manifest. For example this allows reusing the Kubernetes definitions used to deploy some runtime components in production.",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "uri"
+ ]
+ },
+ {
+ "required": [
+ "inlined"
+ ]
+ }
+ ],
+ "properties": {
+ "deployByDefault": {
+ "description": "Defines if the component should be deployed during startup.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "annotation": {
+ "description": "Annotations to be added to Kubernetes Ingress or Openshift Route",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant string-based free-form attributes.\n\nExamples of Che-specific attributes:\n- cookiesAuthEnabled: \"true\" / \"false\",\n- type: \"terminal\" / \"ide\" / \"ide-dev\",",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "exposure": {
+ "description": "Describes how the endpoint should be exposed on the network.\n- `public` means that the endpoint will be exposed on the public network, typically through a K8S ingress or an OpenShift route.\n- `internal` means that the endpoint will be exposed internally outside of the main devworkspace POD, typically by K8S services, to be consumed by other elements running on the same cloud internal network.\n- `none` means that the endpoint will not be exposed and will only be accessible inside the main devworkspace POD, on a local address.\n\nDefault value is `public`",
+ "type": "string",
+ "enum": [
+ "public",
+ "internal",
+ "none"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 15,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "Path of the endpoint URL",
+ "type": "string"
+ },
+ "protocol": {
+ "description": "Describes the application and transport protocols of the traffic that will go through this endpoint.\n- `http`: Endpoint will have `http` traffic, typically on a TCP connection. It will be automaticaly promoted to `https` when the `secure` field is set to `true`.\n- `https`: Endpoint will have `https` traffic, typically on a TCP connection.\n- `ws`: Endpoint will have `ws` traffic, typically on a TCP connection. It will be automaticaly promoted to `wss` when the `secure` field is set to `true`.\n- `wss`: Endpoint will have `wss` traffic, typically on a TCP connection.\n- `tcp`: Endpoint will have traffic on a TCP connection, without specifying an application protocol.\n- `udp`: Endpoint will have traffic on an UDP connection, without specifying an application protocol.\n\nDefault value is `http`",
+ "type": "string",
+ "enum": [
+ "http",
+ "https",
+ "ws",
+ "wss",
+ "tcp",
+ "udp"
+ ]
+ },
+ "secure": {
+ "description": "Describes whether the endpoint should be secured and protected by some authentication process. This requires a protocol of `https` or `wss`.",
+ "type": "boolean"
+ },
+ "targetPort": {
+ "description": "Port number to be used within the container component. The same port cannot be used by two different container components.",
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "inlined": {
+ "description": "Inlined manifest",
+ "type": "string"
+ },
+ "uri": {
+ "description": "Location in a file fetched from a uri.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Mandatory name that allows referencing the component from other elements (such as commands) or from an external devfile that may reference this component through a parent or a plugin.",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "openshift": {
+ "description": "Allows importing into the devworkspace the OpenShift resources defined in a given manifest. For example this allows reusing the OpenShift definitions used to deploy some runtime components in production.",
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "uri"
+ ]
+ },
+ {
+ "required": [
+ "inlined"
+ ]
+ }
+ ],
+ "properties": {
+ "deployByDefault": {
+ "description": "Defines if the component should be deployed during startup.\n\nDefault value is `false`",
+ "type": "boolean"
+ },
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "annotation": {
+ "description": "Annotations to be added to Kubernetes Ingress or Openshift Route",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "attributes": {
+ "description": "Map of implementation-dependant string-based free-form attributes.\n\nExamples of Che-specific attributes:\n- cookiesAuthEnabled: \"true\" / \"false\",\n- type: \"terminal\" / \"ide\" / \"ide-dev\",",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "exposure": {
+ "description": "Describes how the endpoint should be exposed on the network.\n- `public` means that the endpoint will be exposed on the public network, typically through a K8S ingress or an OpenShift route.\n- `internal` means that the endpoint will be exposed internally outside of the main devworkspace POD, typically by K8S services, to be consumed by other elements running on the same cloud internal network.\n- `none` means that the endpoint will not be exposed and will only be accessible inside the main devworkspace POD, on a local address.\n\nDefault value is `public`",
+ "type": "string",
+ "enum": [
+ "public",
+ "internal",
+ "none"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 15,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "path": {
+ "description": "Path of the endpoint URL",
+ "type": "string"
+ },
+ "protocol": {
+ "description": "Describes the application and transport protocols of the traffic that will go through this endpoint.\n- `http`: Endpoint will have `http` traffic, typically on a TCP connection. It will be automaticaly promoted to `https` when the `secure` field is set to `true`.\n- `https`: Endpoint will have `https` traffic, typically on a TCP connection.\n- `ws`: Endpoint will have `ws` traffic, typically on a TCP connection. It will be automaticaly promoted to `wss` when the `secure` field is set to `true`.\n- `wss`: Endpoint will have `wss` traffic, typically on a TCP connection.\n- `tcp`: Endpoint will have traffic on a TCP connection, without specifying an application protocol.\n- `udp`: Endpoint will have traffic on an UDP connection, without specifying an application protocol.\n\nDefault value is `http`",
+ "type": "string",
+ "enum": [
+ "http",
+ "https",
+ "ws",
+ "wss",
+ "tcp",
+ "udp"
+ ]
+ },
+ "secure": {
+ "description": "Describes whether the endpoint should be secured and protected by some authentication process. This requires a protocol of `https` or `wss`.",
+ "type": "boolean"
+ },
+ "targetPort": {
+ "description": "Port number to be used within the container component. The same port cannot be used by two different container components.",
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "inlined": {
+ "description": "Inlined manifest",
+ "type": "string"
+ },
+ "uri": {
+ "description": "Location in a file fetched from a uri.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "volume": {
+ "description": "Allows specifying the definition of a volume shared by several other components",
+ "type": "object",
+ "properties": {
+ "ephemeral": {
+ "description": "Ephemeral volumes are not stored persistently across restarts. Defaults to false",
+ "type": "boolean"
+ },
+ "size": {
+ "description": "Size of the volume",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "dependentProjects": {
+ "description": "Overrides of dependentProjects encapsulated in a parent devfile. Overriding is done according to K8S strategic merge patch standard rules.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "git"
+ ]
+ },
+ {
+ "required": [
+ "zip"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "clonePath": {
+ "description": "Path relative to the root of the projects to which this project should be cloned into. This is a unix-style relative path (i.e. uses forward slashes). The path is invalid if it is absolute or tries to escape the project root through the usage of '..'. If not specified, defaults to the project name.",
+ "type": "string"
+ },
+ "git": {
+ "description": "Project's Git source",
+ "type": "object",
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Project name",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "zip": {
+ "description": "Project's Zip source",
+ "type": "object",
+ "properties": {
+ "location": {
+ "description": "Zip project's source location address. Should be file path of the archive, e.g. file://$FILE_PATH",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "id": {
+ "description": "Id in a registry that contains a Devfile yaml file",
+ "type": "string"
+ },
+ "kubernetes": {
+ "description": "Reference to a Kubernetes CRD of type DevWorkspaceTemplate",
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "namespace": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "projects": {
+ "description": "Overrides of projects encapsulated in a parent devfile. Overriding is done according to K8S strategic merge patch standard rules.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "git"
+ ]
+ },
+ {
+ "required": [
+ "zip"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "clonePath": {
+ "description": "Path relative to the root of the projects to which this project should be cloned into. This is a unix-style relative path (i.e. uses forward slashes). The path is invalid if it is absolute or tries to escape the project root through the usage of '..'. If not specified, defaults to the project name.",
+ "type": "string"
+ },
+ "git": {
+ "description": "Project's Git source",
+ "type": "object",
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Project name",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "zip": {
+ "description": "Project's Zip source",
+ "type": "object",
+ "properties": {
+ "location": {
+ "description": "Zip project's source location address. Should be file path of the archive, e.g. file://$FILE_PATH",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "registryUrl": {
+ "description": "Registry URL to pull the parent devfile from when using id in the parent reference. To ensure the parent devfile gets resolved consistently in different environments, it is recommended to always specify the `registryUrl` when `id` is used.",
+ "type": "string"
+ },
+ "starterProjects": {
+ "description": "Overrides of starterProjects encapsulated in a parent devfile. Overriding is done according to K8S strategic merge patch standard rules.",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "git"
+ ]
+ },
+ {
+ "required": [
+ "zip"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "description": {
+ "description": "Description of a starter project",
+ "type": "string"
+ },
+ "git": {
+ "description": "Project's Git source",
+ "type": "object",
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Project name",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "subDir": {
+ "description": "Sub-directory from a starter project to be used as root for starter project.",
+ "type": "string"
+ },
+ "zip": {
+ "description": "Project's Zip source",
+ "type": "object",
+ "properties": {
+ "location": {
+ "description": "Zip project's source location address. Should be file path of the archive, e.g. file://$FILE_PATH",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "uri": {
+ "description": "URI Reference of a parent devfile YAML file. It can be a full URL or a relative URI with the current devfile as the base URI.",
+ "type": "string"
+ },
+ "variables": {
+ "description": "Overrides of variables encapsulated in a parent devfile. Overriding is done according to K8S strategic merge patch standard rules.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "version": {
+ "description": "Specific stack/sample version to pull the parent devfile from, when using id in the parent reference. To specify `version`, `id` must be defined and used as the import reference source. `version` can be either a specific stack version, or `latest`. If no `version` specified, default version will be used.",
+ "type": "string",
+ "pattern": "^(latest)|(([1-9])\\.([0-9]+)\\.([0-9]+)(\\-[0-9a-z-]+(\\.[0-9a-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?)$"
+ }
+ },
+ "additionalProperties": false
+ },
+ "projects": {
+ "description": "Projects worked on in the devworkspace, containing names and sources locations",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "git"
+ ]
+ },
+ {
+ "required": [
+ "zip"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "clonePath": {
+ "description": "Path relative to the root of the projects to which this project should be cloned into. This is a unix-style relative path (i.e. uses forward slashes). The path is invalid if it is absolute or tries to escape the project root through the usage of '..'. If not specified, defaults to the project name.",
+ "type": "string"
+ },
+ "git": {
+ "description": "Project's Git source",
+ "type": "object",
+ "required": [
+ "remotes"
+ ],
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Project name",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "zip": {
+ "description": "Project's Zip source",
+ "type": "object",
+ "properties": {
+ "location": {
+ "description": "Zip project's source location address. Should be file path of the archive, e.g. file://$FILE_PATH",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "schemaVersion": {
+ "description": "Devfile schema version",
+ "type": "string",
+ "pattern": "^([2-9])\\.([0-9]+)\\.([0-9]+)(\\-[0-9a-z-]+(\\.[0-9a-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$"
+ },
+ "starterProjects": {
+ "description": "StarterProjects is a project that can be used as a starting point when bootstrapping new projects",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "oneOf": [
+ {
+ "required": [
+ "git"
+ ]
+ },
+ {
+ "required": [
+ "zip"
+ ]
+ }
+ ],
+ "properties": {
+ "attributes": {
+ "description": "Map of implementation-dependant free-form YAML attributes.",
+ "type": "object",
+ "additionalProperties": true
+ },
+ "description": {
+ "description": "Description of a starter project",
+ "type": "string"
+ },
+ "git": {
+ "description": "Project's Git source",
+ "type": "object",
+ "required": [
+ "remotes"
+ ],
+ "properties": {
+ "checkoutFrom": {
+ "description": "Defines from what the project should be checked out. Required if there are more than one remote configured",
+ "type": "object",
+ "properties": {
+ "remote": {
+ "description": "The remote name should be used as init. Required if there are more than one remote configured",
+ "type": "string"
+ },
+ "revision": {
+ "description": "The revision to checkout from. Should be branch name, tag or commit id. Default branch is used if missing or specified revision is not found.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "remotes": {
+ "description": "The remotes map which should be initialized in the git project. Projects must have at least one remote configured while StarterProjects \u0026 Image Component's Git source can only have at most one remote configured.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "name": {
+ "description": "Project name",
+ "type": "string",
+ "maxLength": 63,
+ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
+ },
+ "subDir": {
+ "description": "Sub-directory from a starter project to be used as root for starter project.",
+ "type": "string"
+ },
+ "zip": {
+ "description": "Project's Zip source",
+ "type": "object",
+ "properties": {
+ "location": {
+ "description": "Zip project's source location address. Should be file path of the archive, e.g. file://$FILE_PATH",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "variables": {
+ "description": "Map of key-value variables used for string replacement in the devfile. Values can be referenced via {{variable-key}} to replace the corresponding value in string fields in the devfile. Replacement cannot be used for\n\n - schemaVersion, metadata, parent source\n\n - element identifiers, e.g. command id, component name, endpoint name, project name\n\n - references to identifiers, e.g. in events, a command's component, container's volume mount name\n\n - string enums, e.g. command group kind, endpoint exposure",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+}
\ No newline at end of file
diff --git a/tests/unit/devfile-generator.spec.js b/tests/unit/devfile-generator.spec.js
new file mode 100644
index 0000000..4541133
--- /dev/null
+++ b/tests/unit/devfile-generator.spec.js
@@ -0,0 +1,227 @@
+import { expect, test } from '@playwright/test'
+import yaml from 'js-yaml'
+import {
+ buildCommand,
+ buildComponent,
+ buildDevfile,
+ buildProject,
+ generateYaml,
+ parseAttributes,
+ parseQuantity
+} from '../../src/utils/devfileGenerator'
+import {
+ createCommand,
+ createComponent,
+ createInitialDevfileData,
+ createParent,
+ createProject
+} from '../../src/utils/factories'
+import { fullCoverageState } from './fixtures/devfile-states'
+
+test.describe('serialization', () => {
+ test('an untouched wizard only produces the schema version', () => {
+ expect(buildDevfile(createInitialDevfileData())).toEqual({ schemaVersion: '2.3.0' })
+ })
+
+ test('blank values are left out and strings are trimmed', () => {
+ const data = createInitialDevfileData()
+ data.metadata.name = ' my-project '
+ data.metadata.displayName = ' '
+ data.metadata.tags = ['java', ' ', 'quarkus']
+
+ expect(buildDevfile(data).metadata).toEqual({ name: 'my-project', tags: ['java', 'quarkus'] })
+ })
+
+ test('empty sections are dropped instead of being emitted empty', () => {
+ const data = createInitialDevfileData()
+ data.components = [createComponent('container')]
+ data.components[0].name = 'tools'
+ data.components[0].container.image = 'quay.io/devfile/base-developer-image:ubi10-latest'
+
+ const devfile = buildDevfile(data)
+
+ expect(Object.keys(devfile)).toEqual(['schemaVersion', 'components'])
+ expect(devfile.components[0].container).toEqual({
+ image: 'quay.io/devfile/base-developer-image:ubi10-latest',
+ mountSources: true
+ })
+ })
+
+ test('booleans that default to false are only emitted when true', () => {
+ const component = createComponent('volume')
+ component.name = 'm2'
+
+ expect(buildComponent(component).volume).toEqual({})
+
+ component.volume.ephemeral = true
+ expect(buildComponent(component).volume).toEqual({ ephemeral: true })
+ })
+
+ test('tri-state booleans keep an explicit false', () => {
+ const component = createComponent('kubernetes')
+ component.name = 'k8s'
+ component.kubernetes.uri = 'manifest.yaml'
+ component.kubernetes.deployByDefault = false
+
+ expect(buildComponent(component).kubernetes).toEqual({ uri: 'manifest.yaml', deployByDefault: false })
+ })
+
+ test('parent overrides keep a false boolean that top level components drop', () => {
+ const component = createComponent('container', true)
+ component.name = 'tools'
+ component.container.dedicatedPod = false
+ component.container.mountSources = false
+
+ expect(buildComponent(component, true).container).toEqual({ dedicatedPod: false, mountSources: false })
+ expect(buildComponent(component, false).container).toEqual({ mountSources: false })
+ })
+
+ test('key/value lists become maps, keeping the last value of a repeated key', () => {
+ const data = createInitialDevfileData()
+ data.variables = [
+ { key: 'VERSION', value: '1.0.0' },
+ { key: '', value: 'dropped' },
+ { key: 'VERSION', value: '2.0.0' }
+ ]
+
+ expect(buildDevfile(data).variables).toEqual({ VERSION: '2.0.0' })
+ })
+
+ test('environment variables keep an empty value', () => {
+ const command = createCommand('exec')
+ command.id = 'build'
+ command.exec.component = 'tools'
+ command.exec.commandLine = 'make'
+ command.exec.env = [{ name: 'DEBUG', value: '' }, { name: '', value: 'dropped' }]
+
+ expect(buildCommand(command).exec.env).toEqual([{ name: 'DEBUG', value: '' }])
+ })
+
+ test('only the selected component type is emitted', () => {
+ const component = createComponent('image')
+ component.name = 'my-image'
+ component.image.imageName = 'quay.io/org/app:latest'
+ component.image.dockerfile.uri = 'Dockerfile'
+
+ const built = buildComponent(component)
+
+ expect(Object.keys(built)).toEqual(['name', 'image'])
+ expect(built.image.dockerfile).toEqual({ uri: 'Dockerfile' })
+ })
+
+ test('only the selected command type is emitted', () => {
+ const command = createCommand('composite')
+ command.id = 'all'
+ command.composite.commands = ['build', 'test']
+
+ expect(buildCommand(command)).toEqual({ id: 'all', composite: { commands: ['build', 'test'] } })
+ })
+
+ test('a project uses either a git or a zip source', () => {
+ const project = createProject('projects')
+ project.name = 'sample'
+ project.git.remotes = [{ key: 'origin', value: 'https://github.com/devfile/api.git' }]
+
+ expect(buildProject(project)).toEqual({
+ name: 'sample',
+ git: { remotes: { origin: 'https://github.com/devfile/api.git' } }
+ })
+
+ project.git = null
+ project.zip = { location: 'https://example.com/a.zip' }
+
+ expect(buildProject(project)).toEqual({ name: 'sample', zip: { location: 'https://example.com/a.zip' } })
+ })
+
+ test('starter projects carry description and subDir instead of clonePath', () => {
+ const starter = createProject('starterProjects')
+ starter.name = 'starter'
+ starter.description = 'A starter'
+ starter.subDir = 'examples'
+ starter.clonePath = 'ignored'
+ starter.git.remotes = [{ key: 'origin', value: 'https://github.com/devfile/api.git' }]
+
+ const built = buildProject(starter, 'starterProjects')
+
+ expect(built.description).toBe('A starter')
+ expect(built.subDir).toBe('examples')
+ expect(built.clonePath).toBeUndefined()
+ })
+
+ test('registryUrl and version are only emitted for a parent referenced by id', () => {
+ const data = createInitialDevfileData()
+ data.parent = { ...createParent(), uri: 'https://example.com/devfile.yaml', registryUrl: 'https://registry.devfile.io', version: '1.0.0' }
+
+ expect(buildDevfile(data).parent).toEqual({ uri: 'https://example.com/devfile.yaml' })
+
+ data.parent = { ...createParent(), uri: null, id: 'nodejs', registryUrl: 'https://registry.devfile.io', version: '1.0.0' }
+
+ expect(buildDevfile(data).parent).toEqual({
+ id: 'nodejs',
+ registryUrl: 'https://registry.devfile.io',
+ version: '1.0.0'
+ })
+ })
+})
+
+test.describe('attributes', () => {
+ test('free-form YAML is parsed into the devfile', () => {
+ const data = createInitialDevfileData()
+ data.attributes = 'controller.devfile.io/storage-type: ephemeral\nnested:\n key: value'
+
+ expect(buildDevfile(data).attributes).toEqual({
+ 'controller.devfile.io/storage-type': 'ephemeral',
+ nested: { key: 'value' }
+ })
+ })
+
+ test('invalid or non-map YAML is reported and not emitted', () => {
+ expect(parseAttributes('key: [unterminated').error).toBeTruthy()
+ expect(parseAttributes('- a list').error).toBe('Attributes must be a YAML map of key/value pairs')
+ expect(parseAttributes(' ')).toEqual({ value: undefined, error: null })
+
+ const data = createInitialDevfileData()
+ data.attributes = '- a list'
+ expect(buildDevfile(data).attributes).toBeUndefined()
+ })
+})
+
+test.describe('YAML output', () => {
+ test('the generated YAML parses back into the generated object', () => {
+ const data = fullCoverageState()
+
+ expect(yaml.load(generateYaml(data))).toEqual(buildDevfile(data))
+ })
+
+ test('keys keep the schema order and long lines are not wrapped', () => {
+ const data = fullCoverageState()
+ const lines = generateYaml(data).split('\n')
+
+ expect(lines[0]).toBe('schemaVersion: 2.3.0')
+ expect(lines[1]).toBe('metadata:')
+ expect(lines.some((line) => line.endsWith('\\'))).toBe(false)
+ })
+})
+
+test.describe('quantities', () => {
+ const valid = [
+ ['1Gi', 2 ** 30],
+ ['512Mi', 512 * 2 ** 20],
+ ['500m', 0.5],
+ ['2', 2],
+ ['1e3', 1000],
+ ['1.5G', 1.5e9]
+ ]
+
+ for (const [value, expected] of valid) {
+ test(`parses ${value}`, () => {
+ expect(parseQuantity(value)).toBeCloseTo(expected, 5)
+ })
+ }
+
+ for (const value of ['', 'abc', '1Gib', '1 Gi', 'Gi']) {
+ test(`rejects ${JSON.stringify(value)}`, () => {
+ expect(parseQuantity(value)).toBeNull()
+ })
+ }
+})
diff --git a/tests/unit/devfile-validation.spec.js b/tests/unit/devfile-validation.spec.js
new file mode 100644
index 0000000..6a96feb
--- /dev/null
+++ b/tests/unit/devfile-validation.spec.js
@@ -0,0 +1,583 @@
+import { expect, test } from '@playwright/test'
+import { validateDevfile } from '../../src/utils/devfileGenerator'
+import {
+ createCommand,
+ createComponent,
+ createInitialDevfileData,
+ createParent,
+ createProject
+} from '../../src/utils/factories'
+import { fullCoverageState } from './fixtures/devfile-states'
+
+/** A wizard state with a name and the given sections filled in. */
+function state(sections = {}) {
+ const data = createInitialDevfileData()
+ data.metadata.name = 'sample'
+ return { ...data, ...sections }
+}
+
+function container(name, overrides = {}) {
+ const component = createComponent('container')
+ component.name = name
+ component.container.image = 'quay.io/devfile/base-developer-image:ubi10-latest'
+ Object.assign(component.container, overrides)
+ return component
+}
+
+function exec(id, overrides = {}) {
+ const command = createCommand('exec')
+ command.id = id
+ command.exec.component = 'tools'
+ command.exec.commandLine = 'make'
+ Object.assign(command.exec, overrides)
+ return command
+}
+
+function errorsOf(data) {
+ return validateDevfile(data).errors
+}
+
+function warningsOf(data) {
+ return validateDevfile(data).warnings
+}
+
+/** Asserts that exactly one message mentions the given fragment. */
+function expectMessage(messages, fragment) {
+ expect(messages.filter((message) => message.includes(fragment)), messages.join('\n')).toHaveLength(1)
+}
+
+test.describe('schema version and metadata', () => {
+ test('the schema version is required and must be a devfile version', () => {
+ expectMessage(errorsOf(state({ schemaVersion: '' })), 'Schema version is required')
+ expectMessage(errorsOf(state({ schemaVersion: '1.0.0' })), 'is not a valid devfile schema version')
+ })
+
+ test('name and generateName are mutually exclusive', () => {
+ const data = state()
+ data.metadata.generateName = 'sample-'
+
+ expectMessage(errorsOf(data), 'name and generateName are mutually exclusive')
+ })
+
+ test('a devfile without any name only warns', () => {
+ const data = createInitialDevfileData()
+
+ expect(errorsOf(data)).toEqual([])
+ expectMessage(warningsOf(data), 'no devfile name set')
+ })
+
+ test('names, versions, URIs and quantities are checked', () => {
+ const data = state()
+ data.metadata.name = 'Not A Name'
+ data.metadata.version = 'one'
+ data.metadata.website = 'not a url'
+ data.metadata.globalMemoryLimit = 'plenty'
+
+ const errors = errorsOf(data)
+
+ expectMessage(errors, 'name must be lowercase alphanumeric')
+ expectMessage(errors, 'must follow semantic versioning')
+ expectMessage(errors, 'website "not a url" is not a valid URI')
+ expectMessage(errors, 'globalMemoryLimit "plenty" is not a valid quantity')
+ })
+
+ test('attributes that are not a YAML map are reported', () => {
+ const data = state()
+ data.attributes = 'key: [unterminated'
+
+ expectMessage(errorsOf(data), 'Devfile attributes: invalid attributes YAML')
+ })
+})
+
+test.describe('projects', () => {
+ test('a name is required and must be unique', () => {
+ const first = createProject('projects')
+ const second = createProject('projects')
+ first.git.remotes = [{ key: 'origin', value: 'https://github.com/devfile/api.git' }]
+ second.git.remotes = [{ key: 'origin', value: 'https://github.com/devfile/api.git' }]
+ first.name = 'sample'
+ second.name = 'sample'
+
+ expectMessage(errorsOf(state({ projects: [first, second] })), 'duplicate name "sample"')
+
+ first.name = ''
+ expectMessage(errorsOf(state({ projects: [first] })), 'name is required')
+ })
+
+ test('a git source needs a remote, and a checkout remote when there are several', () => {
+ const project = createProject('projects')
+ project.name = 'sample'
+ project.git.remotes = []
+
+ expectMessage(errorsOf(state({ projects: [project] })), 'needs at least one remote')
+
+ project.git.remotes = [
+ { key: 'origin', value: 'https://github.com/devfile/api.git' },
+ { key: 'fork', value: 'https://github.com/ibuziuk/api.git' }
+ ]
+ expectMessage(errorsOf(state({ projects: [project] })), 'checkoutFrom.remote is required')
+
+ project.git.checkoutFrom.remote = 'upstream'
+ expectMessage(errorsOf(state({ projects: [project] })), 'is not one of the configured remotes')
+
+ project.git.checkoutFrom.remote = 'fork'
+ expect(errorsOf(state({ projects: [project] }))).toEqual([])
+ })
+
+ test('a starter project only accepts a single remote', () => {
+ const starter = createProject('starterProjects')
+ starter.name = 'starter'
+ starter.git.remotes = [
+ { key: 'origin', value: 'https://github.com/devfile/api.git' },
+ { key: 'fork', value: 'https://github.com/ibuziuk/api.git' }
+ ]
+
+ expectMessage(errorsOf(state({ starterProjects: [starter] })), 'can only have one remote')
+ })
+
+ test('a remote without a URL is reported', () => {
+ const project = createProject('projects')
+ project.name = 'sample'
+
+ // The editor starts a project off with an empty "origin" remote.
+ expectMessage(errorsOf(state({ projects: [project] })), 'git remote "origin" has no URL')
+ })
+
+ test('two remotes cannot share a name', () => {
+ const project = createProject('projects')
+ project.name = 'sample'
+ project.git.remotes = [
+ { key: 'origin', value: 'https://github.com/devfile/api.git' },
+ { key: 'origin', value: 'https://github.com/ibuziuk/api.git' }
+ ]
+
+ // Serializing keeps the last one, so the first would be lost silently.
+ expectMessage(errorsOf(state({ projects: [project] })), 'duplicate git remote "origin"')
+ })
+
+ test('a zip source without a location warns', () => {
+ const project = createProject('projects')
+ project.name = 'sample'
+ project.git = null
+ project.zip = { location: '' }
+
+ expectMessage(warningsOf(state({ projects: [project] })), 'zip source has no location')
+ })
+})
+
+test.describe('components', () => {
+ test('a container needs an image and a unique, valid name', () => {
+ const component = container('tools', { image: '' })
+
+ expectMessage(errorsOf(state({ components: [component] })), 'container image is required')
+
+ component.name = 'Tools'
+ expectMessage(errorsOf(state({ components: [component] })), 'name must be lowercase alphanumeric')
+
+ expectMessage(errorsOf(state({ components: [container('tools'), container('tools')] })), 'duplicate name "tools"')
+ })
+
+ test('reserved environment variables cannot be redefined', () => {
+ const component = container('tools', { env: [{ name: 'PROJECT_SOURCE', value: '/projects' }] })
+
+ expectMessage(errorsOf(state({ components: [component] })), '"PROJECT_SOURCE" is a reserved environment variable')
+ })
+
+ test('resource quantities are parsed and compared', () => {
+ const component = container('tools', { memoryLimit: 'lots' })
+ expectMessage(errorsOf(state({ components: [component] })), 'memoryLimit "lots" is not a valid quantity')
+
+ const tight = container('tools', { memoryRequest: '2Gi', memoryLimit: '1Gi', cpuRequest: '2', cpuLimit: '500m' })
+ const errors = errorsOf(state({ components: [tight] }))
+ expectMessage(errors, 'memoryRequest is greater than memoryLimit')
+ expectMessage(errors, 'cpuRequest is greater than cpuLimit')
+ })
+
+ test('a volume mount must reference a volume component', () => {
+ const component = container('tools', { volumeMounts: [{ name: 'm2', path: '/home/user/.m2' }] })
+
+ expectMessage(errorsOf(state({ components: [component] })), 'does not reference a volume component')
+
+ const volume = createComponent('volume')
+ volume.name = 'm2'
+ expect(errorsOf(state({ components: [component, volume] }))).toEqual([])
+ })
+
+ test('a volume mount name must be a valid name', () => {
+ const component = container('tools', { volumeMounts: [{ name: 'Not A Name', path: '/data' }] })
+
+ expectMessage(
+ errorsOf(state({ components: [component] })),
+ 'volume mount name "Not A Name" must be lowercase alphanumeric'
+ )
+ })
+
+ test('entries that would be dropped on serialization are reported', () => {
+ const nameless = container('tools', {
+ env: [{ name: '', value: 'lost' }],
+ volumeMounts: [{ name: '', path: '/data' }],
+ annotation: { deployment: [{ key: '', value: 'lost' }], service: [] }
+ })
+
+ const errors = errorsOf(state({ components: [nameless] }))
+ expectMessage(errors, 'an environment variable has a value but no name')
+ expectMessage(errors, 'a volume mount has a value but no name')
+ expectMessage(errors, 'a deployment annotation has a value but no name')
+ })
+
+ test('containers sharing the pod cannot give the same annotation two values', () => {
+ const annotated = (name, value) =>
+ container(name, { annotation: { deployment: [{ key: 'owner', value }], service: [] } })
+
+ expectMessage(
+ errorsOf(state({ components: [annotated('tools', 'a'), annotated('other', 'b')] })),
+ 'conflicting values to the deployment annotation "owner"'
+ )
+
+ expect(errorsOf(state({ components: [annotated('tools', 'a'), annotated('other', 'a')] }))).toEqual([])
+
+ // A container in its own pod annotates its own deployment.
+ const dedicated = annotated('other', 'b')
+ dedicated.container.dedicatedPod = true
+ expect(errorsOf(state({ components: [annotated('tools', 'a'), dedicated] }))).toEqual([])
+ })
+
+ test('endpoints are named, unique across components and bound to a valid port', () => {
+ const withEndpoint = (name, endpoint) => container(name, { endpoints: [endpoint] })
+
+ expectMessage(
+ errorsOf(state({ components: [withEndpoint('tools', { name: '', targetPort: 8080 })] })),
+ 'name is required'
+ )
+ expectMessage(
+ errorsOf(state({ components: [withEndpoint('tools', { name: 'a-very-long-endpoint', targetPort: 8080 })] })),
+ 'max 15 characters'
+ )
+ expectMessage(
+ errorsOf(state({ components: [withEndpoint('tools', { name: 'http', targetPort: '' })] })),
+ 'targetPort is required'
+ )
+ expectMessage(
+ errorsOf(state({ components: [withEndpoint('tools', { name: 'http', targetPort: 70000 })] })),
+ 'targetPort must be between 1 and 65535'
+ )
+
+ const duplicated = state({
+ components: [
+ withEndpoint('tools', { name: 'http', targetPort: 8080 }),
+ withEndpoint('other', { name: 'http', targetPort: 9090 })
+ ]
+ })
+ expectMessage(errorsOf(duplicated), 'endpoint names must be unique across all components')
+ })
+
+ test('two containers cannot expose the same port unless one has a dedicated pod', () => {
+ const first = container('tools', { endpoints: [{ name: 'http-a', targetPort: 8080 }] })
+ const second = container('other', { endpoints: [{ name: 'http-b', targetPort: 8080 }] })
+
+ expectMessage(errorsOf(state({ components: [first, second] })), 'is already used by another container component')
+
+ second.container.dedicatedPod = true
+ expect(errorsOf(state({ components: [first, second] }))).toEqual([])
+ })
+
+ test('kubernetes and openshift components take exactly one manifest source', () => {
+ const component = createComponent('kubernetes')
+ component.name = 'k8s'
+ component.kubernetes.uri = ''
+
+ expectMessage(errorsOf(state({ components: [component] })), 'exactly one of uri or inlined must be set')
+
+ component.kubernetes.uri = 'https://not a url'
+ expectMessage(errorsOf(state({ components: [component] })), 'is not a valid URI')
+ })
+
+ test('an inlined manifest must be valid YAML', () => {
+ const component = createComponent('kubernetes')
+ component.name = 'k8s'
+ component.kubernetes.uri = null
+ component.kubernetes.inlined = 'kind: [Deployment'
+
+ expectMessage(errorsOf(state({ components: [component] })), 'the inlined manifest is not valid YAML')
+
+ // Several documents in one manifest are allowed.
+ component.kubernetes.inlined = 'kind: Deployment\n---\nkind: Service'
+ expect(errorsOf(state({ components: [component] }))).toEqual([])
+ })
+
+ test('an image component needs a name and exactly one dockerfile source', () => {
+ const component = createComponent('image')
+ component.name = 'my-image'
+
+ let errors = errorsOf(state({ components: [component] }))
+ expectMessage(errors, 'imageName is required')
+ expectMessage(errors, 'a dockerfile source (uri, devfileRegistry or git) must be set')
+
+ component.image.imageName = 'quay.io/org/app:latest'
+ component.image.dockerfile.uri = 'Dockerfile'
+ component.image.dockerfile.devfileRegistry = { id: 'nodejs', registryUrl: '' }
+
+ expectMessage(
+ errorsOf(state({ components: [component] })),
+ 'only one dockerfile source (uri, devfileRegistry or git) can be set'
+ )
+
+ component.image.dockerfile.uri = null
+ expectMessage(warningsOf(state({ components: [component] })), 'no buildContext set')
+ })
+})
+
+test.describe('commands', () => {
+ const tools = () => container('tools')
+
+ test('an id is required, valid and unique', () => {
+ expectMessage(errorsOf(state({ components: [tools()], commands: [exec('')] })), 'id is required')
+ expectMessage(errorsOf(state({ components: [tools()], commands: [exec('Build')] })), 'id must be lowercase')
+ expectMessage(
+ errorsOf(state({ components: [tools()], commands: [exec('build'), exec('build')] })),
+ 'duplicate id "build"'
+ )
+ })
+
+ test('an exec command needs a command line and a container component', () => {
+ expectMessage(
+ errorsOf(state({ components: [tools()], commands: [exec('build', { commandLine: '' })] })),
+ 'commandLine is required'
+ )
+ expectMessage(
+ errorsOf(state({ components: [tools()], commands: [exec('build', { component: '' })] })),
+ 'a component reference is required'
+ )
+ expectMessage(
+ errorsOf(state({ components: [tools()], commands: [exec('build', { component: 'missing' })] })),
+ 'references unknown component "missing"'
+ )
+
+ const volume = createComponent('volume')
+ volume.name = 'm2'
+ expectMessage(
+ errorsOf(state({ components: [tools(), volume], commands: [exec('build', { component: 'm2' })] })),
+ 'exec commands must reference a container component'
+ )
+ })
+
+ test('an apply command targets a deployable component, not a volume', () => {
+ const volume = createComponent('volume')
+ volume.name = 'm2'
+
+ const apply = createCommand('apply')
+ apply.id = 'deploy'
+ apply.apply.component = 'm2'
+
+ expectMessage(
+ errorsOf(state({ components: [volume], commands: [apply] })),
+ 'apply commands must reference a container, kubernetes, openshift or image component'
+ )
+
+ const image = createComponent('image')
+ image.name = 'app-image'
+ image.image.imageName = 'quay.io/org/app:latest'
+ image.image.dockerfile.uri = 'Dockerfile'
+ image.image.dockerfile.buildContext = '.'
+ apply.apply.component = 'app-image'
+
+ expect(errorsOf(state({ components: [volume, image], commands: [apply] }))).toEqual([])
+ })
+
+ test('a composite command cannot reference itself, directly or indirectly', () => {
+ const composite = createCommand('composite')
+ composite.id = 'all'
+ composite.composite.commands = ['all']
+
+ expectMessage(errorsOf(state({ commands: [composite] })), 'cannot reference itself')
+
+ const first = createCommand('composite')
+ first.id = 'first'
+ first.composite.commands = ['second']
+ const second = createCommand('composite')
+ second.id = 'second'
+ second.composite.commands = ['first']
+
+ // The cycle is reported on both commands taking part in it.
+ const cycleErrors = errorsOf(state({ commands: [first, second] }))
+ expect(cycleErrors).toEqual([
+ 'Command "first": a composite command cannot indirectly reference itself (first -> second -> first)',
+ 'Command "second": a composite command cannot indirectly reference itself (second -> first -> second)'
+ ])
+ })
+
+ test('a command group has at most one default command', () => {
+ const build = exec('build', { group: { kind: 'build', isDefault: true } })
+ const rebuild = exec('rebuild', { group: { kind: 'build', isDefault: true } })
+
+ expectMessage(
+ errorsOf(state({ components: [tools()], commands: [build, rebuild] })),
+ 'has more than one default command'
+ )
+
+ rebuild.exec.group.isDefault = false
+ build.exec.group.isDefault = false
+ expectMessage(
+ warningsOf(state({ components: [tools()], commands: [build, rebuild] })),
+ 'has 2 commands but no default one'
+ )
+ })
+})
+
+test.describe('events', () => {
+ test('preStart and postStop only accept apply commands', () => {
+ const data = state({
+ components: [container('tools')],
+ commands: [exec('build')],
+ events: { preStart: ['build'], postStart: [], preStop: [], postStop: [] }
+ })
+
+ expectMessage(errorsOf(data), 'Event preStart: "build" must be an apply command')
+ })
+
+ test('postStart and preStop accept exec commands and composites of exec commands', () => {
+ const composite = createCommand('composite')
+ composite.id = 'all'
+ composite.composite.commands = ['build']
+
+ const data = state({
+ components: [container('tools')],
+ commands: [exec('build'), composite],
+ events: { preStart: [], postStart: ['all'], preStop: ['build'], postStop: [] }
+ })
+
+ expect(errorsOf(data)).toEqual([])
+ })
+
+ test('binding the same command twice warns', () => {
+ const data = state({
+ components: [container('tools')],
+ commands: [exec('build')],
+ events: { preStart: [], postStart: ['build', 'build'], preStop: [], postStop: [] }
+ })
+
+ expect(errorsOf(data)).toEqual([])
+ expectMessage(warningsOf(data), 'Event postStart: "build" is bound more than once')
+ })
+
+ test('an unknown command id is reported', () => {
+ const data = state({
+ events: { preStart: [], postStart: ['missing'], preStop: [], postStop: [] }
+ })
+
+ expectMessage(errorsOf(data), 'does not map to a devfile command')
+ })
+})
+
+test.describe('parent', () => {
+ test('exactly one parent source is required', () => {
+ const data = state({ parent: { ...createParent(), uri: '' } })
+
+ expectMessage(warningsOf(data), 'no parent source configured')
+
+ data.parent = { ...createParent(), uri: 'https://example.com/devfile.yaml', id: 'nodejs' }
+ expectMessage(errorsOf(data), 'exactly one of uri, id or kubernetes must be set')
+ })
+
+ test('the parent fields are checked', () => {
+ const data = state({ parent: { ...createParent(), uri: 'https://not a url' } })
+ expectMessage(errorsOf(data), 'uri "https://not a url" is not a valid URI')
+
+ data.parent = { ...createParent(), uri: null, kubernetes: { name: '', namespace: 'ns' } }
+ expectMessage(errorsOf(data), 'the kubernetes reference needs a name')
+
+ data.parent = { ...createParent(), uri: null, id: 'nodejs', version: 'one' }
+ expectMessage(errorsOf(data), 'version "one" must be "latest" or a semantic version')
+ expectMessage(warningsOf(data), 'no registryUrl set')
+ })
+
+ test('references that the parent may provide are warnings instead of errors', () => {
+ const data = state({
+ parent: { ...createParent(), uri: 'https://example.com/devfile.yaml' },
+ commands: [exec('build', { component: 'inherited' })]
+ })
+
+ expect(errorsOf(data)).toEqual([])
+ expectMessage(warningsOf(data), 'references unknown component "inherited"')
+ })
+
+ test('overrides do not require the fields a top level element needs', () => {
+ const parent = createParent()
+ parent.uri = 'https://example.com/devfile.yaml'
+
+ const component = createComponent('container', true)
+ component.name = 'tools'
+ parent.components = [component]
+
+ const command = createCommand('exec', true)
+ command.id = 'build'
+ parent.commands = [command]
+
+ expect(errorsOf(state({ parent }))).toEqual([])
+ })
+
+ test('an override still needs a name and one component type', () => {
+ const parent = createParent()
+ parent.uri = 'https://example.com/devfile.yaml'
+ parent.components = [createComponent('container', true), createComponent('container', true)]
+
+ const errors = errorsOf(state({ parent }))
+ expect(errors.filter((error) => error.includes('name is required'))).toHaveLength(2)
+ expect(errors.every((error) => error.startsWith('Parent'))).toBe(true)
+ })
+
+ test('an image override sets either a dockerfile or autoBuild, never both', () => {
+ const parent = createParent()
+ parent.uri = 'https://example.com/devfile.yaml'
+
+ const component = createComponent('image', true)
+ component.name = 'my-image'
+ component.image.dockerfile.uri = 'Dockerfile'
+ component.image.autoBuild = true
+ parent.components = [component]
+
+ expectMessage(errorsOf(state({ parent })), 'must set exactly one of dockerfile or autoBuild')
+
+ component.image.autoBuild = undefined
+ expect(errorsOf(state({ parent }))).toEqual([])
+
+ component.image.dockerfile = null
+ component.image.autoBuild = false
+ expect(errorsOf(state({ parent }))).toEqual([])
+ })
+})
+
+test.describe('variables', () => {
+ test('a variable needs a unique name', () => {
+ expectMessage(
+ errorsOf(state({ variables: [{ key: 'version', value: '1' }, { key: 'version', value: '2' }] })),
+ 'Variables: duplicate variable "version"'
+ )
+
+ expectMessage(
+ errorsOf(state({ variables: [{ key: '', value: '1.0.0' }] })),
+ 'Variables: a variable has a value but no name'
+ )
+ })
+
+ test('a name that is awkward to reference warns', () => {
+ const data = state({ variables: [{ key: 'java version', value: '21' }] })
+
+ expect(errorsOf(data)).toEqual([])
+ expectMessage(warningsOf(data), 'should only use alphanumeric characters, dashes and underscores')
+ })
+
+ test('parent variable overrides are checked too', () => {
+ const parent = createParent()
+ parent.uri = 'https://example.com/devfile.yaml'
+ parent.variables = [{ key: 'version', value: '1' }, { key: 'version', value: '2' }]
+
+ expectMessage(errorsOf(state({ parent })), 'Parent variables: duplicate variable "version"')
+ })
+})
+
+test.describe('a complete devfile', () => {
+ test('passes validation', () => {
+ expect(validateDevfile(fullCoverageState())).toEqual({ errors: [], warnings: [] })
+ })
+})
diff --git a/tests/unit/fixtures/devfile-states.js b/tests/unit/fixtures/devfile-states.js
new file mode 100644
index 0000000..0f641f2
--- /dev/null
+++ b/tests/unit/fixtures/devfile-states.js
@@ -0,0 +1,374 @@
+/**
+ * Wizard states used by the unit tests.
+ *
+ * `fullCoverageStates` together exercise every field of the devfile 2.3.0
+ * schema; `tests/unit/schema-coverage.spec.js` asserts that nothing is left
+ * out. They are written in the shape the wizard keeps in memory (maps as
+ * ordered key/value arrays, attributes as raw YAML text), which is what
+ * `buildDevfile` consumes.
+ */
+
+export function endpoint(name, targetPort, overrides = {}) {
+ return {
+ name,
+ targetPort,
+ exposure: 'public',
+ protocol: 'https',
+ path: '/api',
+ secure: true,
+ annotation: [{ key: 'haproxy.router.openshift.io/balance', value: 'roundrobin' }],
+ attributes: 'discoverable: true',
+ ...overrides
+ }
+}
+
+export function container(overrides = {}) {
+ return {
+ image: 'quay.io/devfile/universal-developer-image:ubi9-latest',
+ command: ['sleep'],
+ args: ['infinity'],
+ env: [{ name: 'MAVEN_OPTS', value: '-Xmx1g' }],
+ volumeMounts: [{ name: 'm2', path: '/home/user/.m2' }],
+ endpoints: [endpoint('http-8080', 8080)],
+ memoryRequest: '512Mi',
+ memoryLimit: '2Gi',
+ cpuRequest: '100m',
+ cpuLimit: '1000m',
+ mountSources: true,
+ sourceMapping: '/projects',
+ dedicatedPod: false,
+ annotation: {
+ deployment: [{ key: 'app.kubernetes.io/part-of', value: 'workspace' }],
+ service: [{ key: 'service.beta.openshift.io/serving-cert-secret-name', value: 'tls' }]
+ },
+ ...overrides
+ }
+}
+
+export function gitSource(overrides = {}) {
+ return {
+ remotes: [
+ { key: 'origin', value: 'https://github.com/devfile/gui-wizard.git' },
+ { key: 'fork', value: 'https://github.com/ibuziuk/gui-wizard.git' }
+ ],
+ checkoutFrom: { remote: 'origin', revision: 'main' },
+ ...overrides
+ }
+}
+
+/** Components covering the five component types and the three dockerfile sources. */
+export function components({ partial = false } = {}) {
+ return [
+ {
+ name: 'tools',
+ attributes: 'controller.devfile.io/merge-contribution: true',
+ container: container()
+ },
+ {
+ name: 'isolated',
+ attributes: '',
+ container: container({
+ image: 'quay.io/centos7/postgresql-13-centos7:latest',
+ command: [],
+ args: [],
+ env: [],
+ volumeMounts: [],
+ endpoints: [endpoint('db-5432', 5432)],
+ dedicatedPod: true,
+ annotation: { deployment: [], service: [] }
+ })
+ },
+ {
+ name: 'm2',
+ attributes: '',
+ volume: { size: '1Gi', ephemeral: true }
+ },
+ {
+ name: 'k8s-resource',
+ attributes: '',
+ kubernetes: {
+ uri: 'manifests/deployment.yaml',
+ inlined: null,
+ deployByDefault: true,
+ endpoints: [endpoint('k8s-9090', 9090)]
+ }
+ },
+ {
+ name: 'ocp-resource',
+ attributes: '',
+ openshift: {
+ uri: null,
+ inlined: 'apiVersion: v1\nkind: Service\nmetadata:\n name: my-service\n',
+ deployByDefault: false,
+ endpoints: [endpoint('ocp-9091', 9091)]
+ }
+ },
+ {
+ name: 'k8s-inlined',
+ attributes: '',
+ kubernetes: {
+ uri: null,
+ inlined: 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: my-config\n',
+ deployByDefault: false,
+ endpoints: []
+ }
+ },
+ {
+ name: 'ocp-uri',
+ attributes: '',
+ openshift: {
+ uri: 'manifests/route.yaml',
+ inlined: null,
+ deployByDefault: true,
+ endpoints: []
+ }
+ },
+ {
+ name: 'image-uri',
+ attributes: '',
+ image: {
+ imageName: 'quay.io/my-org/my-app:latest',
+ autoBuild: partial ? undefined : true,
+ dockerfile: {
+ uri: 'docker/Dockerfile',
+ devfileRegistry: null,
+ git: null,
+ buildContext: '.',
+ rootRequired: true,
+ args: ['--build-arg=VERSION=1.0']
+ }
+ }
+ },
+ {
+ name: 'image-registry',
+ attributes: '',
+ image: {
+ imageName: 'quay.io/my-org/from-registry:latest',
+ autoBuild: undefined,
+ dockerfile: {
+ uri: null,
+ devfileRegistry: { id: 'nodejs', registryUrl: 'https://registry.devfile.io' },
+ git: null,
+ buildContext: 'build',
+ rootRequired: false,
+ args: []
+ }
+ }
+ },
+ {
+ name: 'image-git',
+ attributes: '',
+ image: {
+ imageName: 'quay.io/my-org/from-git:latest',
+ autoBuild: undefined,
+ dockerfile: {
+ uri: null,
+ devfileRegistry: null,
+ git: {
+ remotes: [{ key: 'origin', value: 'https://github.com/devfile/gui-wizard.git' }],
+ checkoutFrom: { remote: 'origin', revision: 'main' },
+ fileLocation: 'docker/Dockerfile'
+ },
+ buildContext: 'build',
+ rootRequired: false,
+ args: []
+ }
+ }
+ }
+ ]
+}
+
+/** Commands covering the three command types, groups, labels and env. */
+export function commands({ partial = false } = {}) {
+ return [
+ {
+ id: 'build',
+ attributes: 'controller.devfile.io/imported-by: parent',
+ exec: {
+ component: 'tools',
+ commandLine: './mvnw clean package',
+ workingDir: '${PROJECT_SOURCE}',
+ env: [{ name: 'MAVEN_ARGS', value: '-DskipTests' }],
+ label: 'Build the application',
+ group: { kind: 'build', isDefault: true },
+ hotReloadCapable: true
+ }
+ },
+ {
+ id: 'start',
+ attributes: '',
+ exec: {
+ component: 'tools',
+ commandLine: './mvnw quarkus:dev',
+ workingDir: '',
+ env: [],
+ label: 'Start in dev mode',
+ group: { kind: 'run', isDefault: true },
+ hotReloadCapable: partial ? undefined : false
+ }
+ },
+ {
+ id: 'deploy-k8s',
+ attributes: '',
+ apply: {
+ component: 'k8s-resource',
+ label: 'Deploy the manifests',
+ group: { kind: 'deploy', isDefault: true }
+ }
+ },
+ {
+ id: 'build-image',
+ attributes: '',
+ apply: {
+ component: 'image-uri',
+ label: 'Build the container image',
+ group: { kind: '', isDefault: partial ? undefined : false }
+ }
+ },
+ {
+ id: 'build-and-start',
+ attributes: '',
+ composite: {
+ commands: ['build', 'start'],
+ parallel: true,
+ label: 'Build then start',
+ group: { kind: 'test', isDefault: true }
+ }
+ }
+ ]
+}
+
+export function projects(kind) {
+ const starter = kind === 'starterProjects'
+
+ const base = {
+ name: `${starter ? 'starter' : 'main'}-git`,
+ attributes: 'app.kubernetes.io/name: sample',
+ git: starter ? gitSource({ remotes: [{ key: 'origin', value: 'https://github.com/devfile/api.git' }] }) : gitSource(),
+ zip: null
+ }
+
+ const archive = {
+ name: `${starter ? 'starter' : 'main'}-zip`,
+ attributes: '',
+ git: null,
+ zip: { location: 'https://example.com/archive.zip' }
+ }
+
+ if (starter) {
+ base.description = 'A sample starter project'
+ base.subDir = 'examples/rest-api'
+ archive.description = 'A zipped starter project'
+ archive.subDir = ''
+ } else {
+ base.clonePath = 'sample'
+ archive.clonePath = 'archive'
+ }
+
+ return [base, archive]
+}
+
+export function parentOverrides() {
+ return {
+ variables: [{ key: 'PARENT_VERSION', value: '2.0.0' }],
+ attributes: 'controller.devfile.io/storage-type: ephemeral',
+ components: [
+ ...components({ partial: true }),
+ {
+ name: 'image-auto',
+ attributes: '',
+ image: { imageName: 'quay.io/my-org/inherited:latest', autoBuild: true, dockerfile: null }
+ }
+ ],
+ commands: commands({ partial: true }),
+ projects: projects('projects'),
+ starterProjects: projects('starterProjects'),
+ dependentProjects: projects('dependentProjects')
+ }
+}
+
+function baseState() {
+ return {
+ schemaVersion: '2.3.0',
+ metadata: {
+ name: 'full-coverage',
+ generateName: '',
+ version: '1.2.3',
+ displayName: 'Full coverage stack',
+ description: 'A devfile exercising every field of the 2.3.0 schema',
+ icon: 'https://example.com/icon.png',
+ language: 'Java',
+ projectType: 'quarkus',
+ provider: 'Devfile community',
+ tags: ['Java', 'Quarkus'],
+ architectures: ['amd64', 'arm64'],
+ website: 'https://devfile.io',
+ supportUrl: 'https://devfile.io/docs',
+ globalMemoryLimit: '2674Mi',
+ attributes: 'alpha.build-context: .'
+ },
+ attributes: 'controller.devfile.io/devworkspace-config:\n name: config\n namespace: devfile',
+ parent: null,
+ projects: projects('projects'),
+ starterProjects: projects('starterProjects'),
+ dependentProjects: projects('dependentProjects'),
+ components: components(),
+ commands: commands(),
+ events: {
+ preStart: ['deploy-k8s'],
+ postStart: ['build-and-start'],
+ preStop: ['start'],
+ postStop: ['build-image']
+ },
+ variables: [{ key: 'VERSION', value: '1.0.0' }]
+ }
+}
+
+/** Everything the schema allows, with a parent referenced by uri. */
+export function fullCoverageState() {
+ return {
+ ...baseState(),
+ parent: {
+ uri: 'https://registry.devfile.io/devfiles/java-quarkus',
+ id: null,
+ kubernetes: null,
+ registryUrl: '',
+ version: '',
+ ...parentOverrides()
+ }
+ }
+}
+
+/** Same devfile, but inheriting from a registry stack. */
+export function parentByIdState() {
+ return {
+ ...baseState(),
+ parent: {
+ uri: null,
+ id: 'java-quarkus',
+ kubernetes: null,
+ registryUrl: 'https://registry.devfile.io',
+ version: '1.2.0',
+ ...parentOverrides()
+ }
+ }
+}
+
+/** Same devfile, but inheriting from a DevWorkspaceTemplate. */
+export function parentByKubernetesState() {
+ return {
+ ...baseState(),
+ parent: {
+ uri: null,
+ id: null,
+ kubernetes: { name: 'my-template', namespace: 'my-namespace' },
+ registryUrl: '',
+ version: '',
+ ...parentOverrides()
+ }
+ }
+}
+
+export function fullCoverageStates() {
+ return [fullCoverageState(), parentByIdState(), parentByKubernetesState()]
+}
diff --git a/tests/unit/schema-coverage.spec.js b/tests/unit/schema-coverage.spec.js
new file mode 100644
index 0000000..ca2f6df
--- /dev/null
+++ b/tests/unit/schema-coverage.spec.js
@@ -0,0 +1,224 @@
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { expect, test } from '@playwright/test'
+import Ajv from 'ajv'
+import { buildDevfile, validateDevfile } from '../../src/utils/devfileGenerator'
+import { fullCoverageState, fullCoverageStates } from './fixtures/devfile-states'
+
+/**
+ * Checks the wizard against the published devfile 2.3.0 JSON schema:
+ *
+ * - everything it generates is accepted by the schema,
+ * - every field of the schema can be generated.
+ *
+ * The schema is vendored in `tests/schema` so the suite stays offline and
+ * pinned to the version the wizard targets.
+ */
+
+const schemaPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'schema', 'devfile-2.3.0.json')
+const schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
+
+// `unicodeRegExp` is off because the published schema uses escapes (`\-`) that
+// are not valid in unicode mode.
+const ajv = new Ajv({ strict: false, allErrors: true, unicodeRegExp: false })
+const validateAgainstSchema = ajv.compile(schema)
+
+/** Every field the schema defines, as dotted paths with `[]` for list items. */
+function schemaPaths(node, prefix = '') {
+ const paths = []
+ if (!node?.properties) return paths
+
+ for (const [key, value] of Object.entries(node.properties)) {
+ const path = prefix ? `${prefix}.${key}` : key
+
+ if (value.type === 'array' && value.items?.properties) {
+ paths.push(...schemaPaths(value.items, `${path}[]`))
+ } else if (value.properties) {
+ paths.push(...schemaPaths(value, path))
+ } else {
+ paths.push(path)
+ }
+ }
+
+ return paths
+}
+
+/** Every field a generated devfile actually contains, in the same notation. */
+function instancePaths(value, prefix = '', found = new Set()) {
+ if (Array.isArray(value)) {
+ for (const item of value) instancePaths(item, `${prefix}[]`, found)
+ return found
+ }
+
+ if (value === null || typeof value !== 'object') return found
+
+ for (const [key, child] of Object.entries(value)) {
+ const path = prefix ? `${prefix}.${key}` : key
+ found.add(path)
+ instancePaths(child, path, found)
+ }
+
+ return found
+}
+
+test.describe('devfile 2.3.0 schema', () => {
+ test('the vendored schema is the 2.3.0 one', () => {
+ expect(schema.title).toBe('Devfile schema - Version 2.3.0')
+ })
+
+ test('generated devfiles validate against the schema', () => {
+ for (const state of fullCoverageStates()) {
+ const devfile = buildDevfile(state)
+ const valid = validateAgainstSchema(devfile)
+
+ expect(
+ valid,
+ `schema errors: ${JSON.stringify(validateAgainstSchema.errors, null, 2)}`
+ ).toBe(true)
+ }
+ })
+
+ test('the full coverage devfiles raise no error and no warning', () => {
+ for (const state of fullCoverageStates()) {
+ const { errors, warnings } = validateDevfile(state)
+
+ expect(errors).toEqual([])
+ expect(warnings).toEqual([])
+ }
+ })
+
+ test('every field of the schema can be generated by the wizard', () => {
+ const generated = new Set()
+ for (const state of fullCoverageStates()) {
+ instancePaths(buildDevfile(state), '', generated)
+ }
+
+ const missing = schemaPaths(schema).filter((path) => !generated.has(path))
+
+ expect(missing, `fields the wizard never generates: ${missing.join(', ')}`).toEqual([])
+ })
+
+ test('the wizard never generates a field the schema does not define', () => {
+ const allowed = new Set(schemaPaths(schema))
+
+ // Free-form maps hold user defined keys, their contents are not described
+ // by the schema.
+ const freeForm = [
+ /(^|\.)attributes(\.|$)/,
+ /(^|\.)variables\./,
+ /(^|\.)remotes\./,
+ /(^|\.)annotation\./,
+ /^metadata\.generateName$/
+ ]
+
+ for (const state of fullCoverageStates()) {
+ const devfile = buildDevfile(state)
+ const unknown = [...instancePaths(devfile)].filter(
+ (path) => !allowed.has(path) && !freeForm.some((pattern) => pattern.test(path))
+ )
+
+ // Paths of intermediate objects and lists are reported by
+ // `instancePaths` but are not leaves of the schema, so only unknown
+ // leaves are left here.
+ const unexpected = unknown.filter(
+ (path) =>
+ ![...allowed].some(
+ (allowedPath) => allowedPath.startsWith(`${path}.`) || allowedPath.startsWith(`${path}[]`)
+ )
+ )
+
+ expect(unexpected, `fields outside the schema: ${unexpected.join(', ')}`).toEqual([])
+ }
+ })
+
+ test('a devfile the wizard accepts is also accepted by the schema', () => {
+ // The constraints the schema puts on values (patterns, enums, lengths and
+ // bounds), each broken in the place the wizard could emit it. Whatever the
+ // wizard lets through here has to stay valid for the schema: a rule that
+ // is only checked at the top level, or not at all, shows up as a failure.
+ const containerOf = (component) => component.container
+ const breakages = {
+ 'component name': (state) => {
+ state.components[0].name = 'Not A Name'
+ },
+ 'volume mount name': (state) => {
+ state.components.find(containerOf).container.volumeMounts[0].name = 'Not A Name'
+ },
+ 'long volume mount name': (state) => {
+ state.components.find(containerOf).container.volumeMounts[0].name = 'm'.repeat(64)
+ },
+ 'override volume mount name': (state) => {
+ state.parent.components.find(containerOf).container.volumeMounts[0].name = 'Not A Name'
+ },
+ 'endpoint name': (state) => {
+ state.components.find(containerOf).container.endpoints[0].name = 'Not An Endpoint'
+ },
+ 'override endpoint name': (state) => {
+ state.parent.components.find(containerOf).container.endpoints[0].name = 'way-too-long-endpoint'
+ },
+ 'endpoint protocol': (state) => {
+ state.components.find(containerOf).container.endpoints[0].protocol = 'ftp'
+ },
+ 'endpoint exposure': (state) => {
+ state.components.find(containerOf).container.endpoints[0].exposure = 'everyone'
+ },
+ 'command id': (state) => {
+ state.commands[0].id = 'Not An Id'
+ },
+ 'override command id': (state) => {
+ state.parent.commands[0].id = 'Not An Id'
+ },
+ 'command group kind': (state) => {
+ state.commands[0].exec.group.kind = 'lint'
+ },
+ 'project name': (state) => {
+ state.projects[0].name = 'Not A Name'
+ },
+ 'override project name': (state) => {
+ state.parent.starterProjects[0].name = 'Not A Name'
+ },
+ 'metadata version': (state) => {
+ state.metadata.version = 'version one'
+ },
+ 'metadata architecture': (state) => {
+ state.metadata.architectures = ['sparc']
+ },
+ 'schema version': (state) => {
+ state.schemaVersion = '1.0.0'
+ },
+ 'parent version': (state) => {
+ // `version` only goes with a registry id.
+ state.parent.uri = null
+ state.parent.id = 'java-quarkus'
+ state.parent.registryUrl = 'https://registry.devfile.io'
+ state.parent.version = '0.1.0'
+ }
+ }
+
+ for (const [what, breakIt] of Object.entries(breakages)) {
+ const state = fullCoverageState()
+ breakIt(state)
+
+ expect(
+ validateAgainstSchema(buildDevfile(state)),
+ `the fixture for a broken ${what} is not a schema violation, the case proves nothing`
+ ).toBe(false)
+
+ expect(
+ validateDevfile(state).errors,
+ `the schema rejects a broken ${what}, the wizard reports no error`
+ ).not.toEqual([])
+ }
+ })
+
+ test('a devfile the wizard rejects is also rejected by the schema', () => {
+ const [state] = fullCoverageStates()
+ state.components[0].container.endpoints[0].targetPort = ''
+
+ const { errors } = validateDevfile(state)
+ expect(errors.join('\n')).toContain('targetPort is required')
+
+ expect(validateAgainstSchema(buildDevfile(state))).toBe(false)
+ })
+})