diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index dfa622f65..868da468f 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -345,11 +345,18 @@ "Content is base64 encoded": "Content is base64 encoded", "Delete file": "Delete file", "Add file": "Add file", - "Inline application": "Inline application", - "Image based application": "Image based application", + "Quadlet application": "Quadlet application", + "Compose application": "Compose application", "Application {{ appNum }}": "Application {{ appNum }}", "Application type": "Application type", "Select an application type": "Select an application type", + "Definition source": "Definition source", + "Configuration Sources": "Configuration Sources", + "OCI reference": "OCI reference", + "Pull definitions from container registry (reusable, versioned).": "Pull definitions from container registry (reusable, versioned).", + "Inline": "Inline", + "Define application files directly in this interface (custom, one-off).": "Define application files directly in this interface (custom, one-off).", + "OCI reference URL": "OCI reference URL", "Application name": "Application name", "The unique identifier for this application.": "The unique identifier for this application.", "If not specified, the image name will be used. Application name must be unique.": "If not specified, the image name will be used. Application name must be unique.", @@ -358,6 +365,7 @@ "Add an application variable": "Add an application variable", "Application workloads": "Application workloads", "Define the application workloads that shall run on the device.": "Define the application workloads that shall run on the device.", + "Configure containerized applications and services that will run on your fleet devices. You can deploy single containers, Quadlet applications for advanced container orchestration or inline applications with custom files.": "Configure containerized applications and services that will run on your fleet devices. You can deploy single containers, Quadlet applications for advanced container orchestration or inline applications with custom files.", "Delete application": "Delete application", "Add application": "Add application", "(0777) Read, write, and execute permissions for all users.": "(0777) Read, write, and execute permissions for all users.", @@ -429,7 +437,8 @@ "The device will download and apply updates as soon as they are available.": "The device will download and apply updates as soon as they are available.", "Device alias": "Device alias", "Device labels": "Device labels", - "Inline": "Inline", + "Quadlet": "Quadlet", + "Compose": "Compose", "Image based": "Image based", "Unnamed": "Unnamed", "Device fleet": "Device fleet", @@ -663,14 +672,20 @@ "Use alphanumeric characters, or underscore (_)": "Use alphanumeric characters, or underscore (_)", "Variable value is required.": "Variable value is required.", "Variable names of an application must be unique": "Variable names of an application must be unique", - "Application type is required": "Application type is required", - "Name is required for inline applications.": "Name is required for inline applications.", + "Definition source is required": "Definition source is required", + "Name is required for {{ appType }} applications.": "Name is required for {{ appType }} applications.", "Use lowercase alphanumeric characters, or dash (-). Must start and end with an alphanumeric character.": "Use lowercase alphanumeric characters, or dash (-). Must start and end with an alphanumeric character.", "File path is required": "File path is required", "File path length cannot exceed {{ maxCharacters }} characters.": "File path length cannot exceed {{ maxCharacters }} characters.", "Application file path must be relative. It cannot be outside the application directory.": "Application file path must be relative. It cannot be outside the application directory.", - "Inline applications must include at least one file.": "Inline applications must include at least one file.", + "Application must include at least one file.": "Application must include at least one file.", "Each file of the same application must use different paths.": "Each file of the same application must use different paths.", + "File name must be one of: {{ allowedFileNames }}": "File name must be one of: {{ allowedFileNames }}", + "Unsupported quadlet file type {{ extension }}. Supported types: {{ supportedTypes }}": "Unsupported quadlet file type {{ extension }}. Supported types: {{ supportedTypes }}", + "Quadlet application must include at least one of the following file types: {{ supportedTypes }}": "Quadlet application must include at least one of the following file types: {{ supportedTypes }}", + "Quadlet files must be at root level (no subdirectories)": "Quadlet files must be at root level (no subdirectories)", + "Definition source must be image for this type of applications": "Definition source must be image for this type of applications", + "Application type is required": "Application type is required", "Image is required.": "Image is required.", "Application image includes invalid characters.": "Application image includes invalid characters.", "Application name must be unique.": "Application name must be unique.", diff --git a/libs/types/models/ApplicationVolume.ts b/libs/types/models/ApplicationVolume.ts index b6c2635db..50ea36136 100644 --- a/libs/types/models/ApplicationVolume.ts +++ b/libs/types/models/ApplicationVolume.ts @@ -2,10 +2,13 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { ImageMountVolumeProviderSpec } from './ImageMountVolumeProviderSpec'; import type { ImageVolumeProviderSpec } from './ImageVolumeProviderSpec'; -export type ApplicationVolume = { +import type { MountVolumeProviderSpec } from './MountVolumeProviderSpec'; +export type ApplicationVolume = ({ /** * Unique name of the volume used within the application. */ name: string; -} & ImageVolumeProviderSpec; +} & (ImageVolumeProviderSpec | MountVolumeProviderSpec | ImageMountVolumeProviderSpec)); + diff --git a/libs/types/models/DeviceApplicationStatus.ts b/libs/types/models/DeviceApplicationStatus.ts index 092877b68..fe941c856 100644 --- a/libs/types/models/DeviceApplicationStatus.ts +++ b/libs/types/models/DeviceApplicationStatus.ts @@ -4,6 +4,7 @@ /* eslint-disable */ import type { ApplicationStatusType } from './ApplicationStatusType'; import type { ApplicationVolumeStatus } from './ApplicationVolumeStatus'; +import type { AppType } from './AppType'; export type DeviceApplicationStatus = { /** * Human readable name of the application. @@ -18,6 +19,11 @@ export type DeviceApplicationStatus = { */ restarts: number; status: ApplicationStatusType; + /** + * Whether the application is embedded in the bootc image. + */ + embedded: boolean; + appType: AppType; /** * Status of volumes used by this application. */ diff --git a/libs/types/models/TokenResponse.ts b/libs/types/models/TokenResponse.ts index 1a2ab3a3a..a5060db66 100644 --- a/libs/types/models/TokenResponse.ts +++ b/libs/types/models/TokenResponse.ts @@ -14,6 +14,10 @@ export type TokenResponse = { * Token type. */ token_type?: TokenResponse.token_type; + /** + * OIDC ID token (JWT). Present when using OIDC with openid scope. + */ + id_token?: string; /** * OAuth2 refresh token. */ diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx index 7332106b6..242632afb 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Bullseye } from '@patternfly/react-core'; +import { Bullseye, Label } from '@patternfly/react-core'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { DeviceApplicationStatus } from '@flightctl/types'; @@ -35,6 +35,7 @@ const ApplicationsTable = ({ appsStatus, specApps }: ApplicationsTableProps) => {t('Status')} {t('Ready')} {t('Restarts')} + {t('Type')} @@ -43,6 +44,7 @@ const ApplicationsTable = ({ appsStatus, specApps }: ApplicationsTableProps) => status: null, ready: '-', restarts: '-', + appType: null, }; return ( @@ -53,6 +55,9 @@ const ApplicationsTable = ({ appsStatus, specApps }: ApplicationsTableProps) => {appDetails.ready} {appDetails.restarts} + + {appDetails.appType ? : '-'} + ); })} diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index e46d1a80f..a6b8f85b0 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -2,12 +2,14 @@ import { AppType, // eslint-disable-next-line no-restricted-imports ApplicationProviderSpec, + ApplicationVolume, ConfigProviderSpec, DeviceSpec, EncodingType, FileSpec, GitConfigProviderSpec, HttpConfigProviderSpec, + ImageMountVolumeProviderSpec, InlineApplicationProviderSpec, InlineConfigProviderSpec, KubernetesSecretProviderSpec, @@ -209,26 +211,38 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { return acc; }, {}); - const volumes = app.volumes?.map((v) => ({ - name: v.name, - image: { - reference: v.reference, - pullPolicy: v.pullPolicy, - }, - })); + const volumes = app.volumes?.map((v) => { + // @ts-expect-error We will only set the fields that are present + const volume: ApplicationVolume = { + name: v.name, + }; + // It's either one of the two fields, or both. + // ImageMountVolumeProviderSpec is the spec that has both fields + if (v.image) { + (volume as ImageMountVolumeProviderSpec).image = v.image; + } + if (v.mount) { + (volume as ImageMountVolumeProviderSpec).mount = v.mount; + } + return volume; + }); if (isImageAppForm(app)) { - const data = { + const data: ApplicationProviderSpec = { image: app.image, + appType: app.appType, envVars, volumes, }; - return app.name ? { ...data, name: app.name } : data; + if (app.name) { + data.name = app.name; + } + return data; } return { name: app.name, - appType: AppType.AppTypeCompose, + appType: app.appType, inline: app.files.map( (file): InlineApplicationFileFixed => ({ path: file.path, @@ -377,36 +391,32 @@ export const getApiConfig = (ct: SpecConfigTemplate): ConfigSourceProvider => { const getAppFormVariables = (app: ApplicationProviderSpecFixed) => Object.entries(app.envVars || {}).map(([varName, varValue]) => ({ name: varName, value: varValue })); -const getAppFormVolumes = (app: ApplicationProviderSpecFixed) => - app.volumes?.map((v) => ({ - name: v.name, - reference: v.image.reference, - pullPolicy: v.image.pullPolicy, - })); - export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => { const apps = deviceSpec?.applications || []; return apps.map((app) => { + if (!app.appType) { + throw new Error('Application appType is required'); + } if (isImageAppProvider(app)) { return { specType: AppSpecType.OCI_IMAGE, name: app.name || '', image: app.image, + appType: app.appType as AppType.AppTypeCompose | AppType.AppTypeQuadlet, variables: getAppFormVariables(app), - volumes: getAppFormVolumes(app), + volumes: app.volumes || [], }; } + + const inlineApp = app as InlineApplicationProviderSpec; return { specType: AppSpecType.INLINE, + appType: app.appType, name: app.name || '', - files: (app as InlineApplicationProviderSpec).inline.map((file) => ({ - path: file.path || '', - content: file.content, - base64: file.contentEncoding === EncodingType.EncodingBase64, - })), + files: inlineApp.inline, variables: getAppFormVariables(app), - volumes: getAppFormVolumes(app), - }; + volumes: app.volumes || [], + } as InlineAppForm; }); }; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx index 871a119b3..f84d788db 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { Button, FormGroup, FormSection, Grid, Split, SplitItem } from '@patternfly/react-core'; +import { Button, FormGroup, FormSection, Grid, Split, SplitItem, Stack, StackItem } from '@patternfly/react-core'; import { FieldArray, useField, useFormikContext } from 'formik'; import { MinusCircleIcon } from '@patternfly/react-icons/dist/js/icons/minus-circle-icon'; import { PlusCircleIcon } from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; +import { TFunction } from 'i18next'; +import { AppType } from '@flightctl/types'; import { AppForm, AppSpecType, @@ -15,6 +17,7 @@ import { import { useTranslation } from '../../../../hooks/useTranslation'; import TextField from '../../../form/TextField'; import FormSelect from '../../../form/FormSelect'; +import RadioField from '../../../form/RadioField'; import ErrorHelperText from '../../../form/FieldHelperText'; import ExpandableFormSection from '../../../form/ExpandableFormSection'; import { FormGroupWithHelperText } from '../../../common/WithHelperText'; @@ -23,46 +26,54 @@ import ApplicationInlineForm from './ApplicationInlineForm'; import './ApplicationsForm.css'; +const appTypeOptions = (t: TFunction) => ({ + [AppType.AppTypeQuadlet]: t('Quadlet application'), + [AppType.AppTypeCompose]: t('Compose application'), +}); + const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: boolean }) => { const { t } = useTranslation(); const appFieldName = `applications[${index}]`; const [{ value: app }, { error }, { setValue }] = useField(appFieldName); - const isInlineIncomplete = app.specType === AppSpecType.INLINE && !('files' in app); const isImageIncomplete = app.specType === AppSpecType.OCI_IMAGE && !('image' in app); + const isInlineIncomplete = app.specType === AppSpecType.INLINE && !('files' in app); + const shouldResetApp = isInlineIncomplete || isImageIncomplete; + // @ts-expect-error Formik error object includes "variables" const appVarsError = typeof error?.variables === 'string' ? (error.variables as string) : undefined; // eslint-disable @typescript-eslint/no-unsafe-assignment - const appTypes = React.useMemo(() => { - return { - [AppSpecType.INLINE]: t('Inline application'), - [AppSpecType.OCI_IMAGE]: t('Image based application'), - }; - }, [t]); + const appTypesOptions = appTypeOptions(t); React.useEffect(() => { - // When switching types, setting the new required fields and clearing those from the old type - if (isInlineIncomplete) { - setValue( - { - specType: AppSpecType.INLINE, - name: app.name || '', - files: [{ path: '', content: '' }], - variables: [], - }, - false, - ); - } else if (isImageIncomplete) { - setValue( - { - specType: AppSpecType.OCI_IMAGE, - name: app.name || '', - image: '', - variables: [], - }, - false, - ); + // When switching specType, the app becomes "incomplete" and we must add the required fields for the new type + if (shouldResetApp) { + if (app.specType === AppSpecType.INLINE) { + // Switching to inline - need files + setValue( + { + specType: AppSpecType.INLINE, + appType: app.appType, + name: app.name || '', + files: [{ path: '', content: '' }], + variables: [], + } as AppForm, + false, + ); + } else if (app.specType === AppSpecType.OCI_IMAGE) { + // Switching to image - need image field + setValue( + { + specType: AppSpecType.OCI_IMAGE, + appType: app.appType, + name: app.name || '', + image: '', + variables: [], + } as AppForm, + false, + ); + } } - }, [isImageIncomplete, isInlineIncomplete, app.name, setValue]); + }, [shouldResetApp, app.specType, app.appType, app.name, setValue]); return ( + + + {t('Configuration Sources')}: + + + +
  • + {t('OCI reference')} -{' '} + {t('Pull definitions from container registry (reusable, versioned).')} +
  • +
  • + {t('Inline')} -{' '} + {t('Define application files directly in this interface (custom, one-off).')} +
  • +
    +
    + + } + > + + + + + + + {' '} + +
    + { label={t('Application workloads')} content={t('Define the application workloads that shall run on the device.')} > - - {({ push, remove }) => ( - <> - {values.applications.map((_app, index) => ( - - - - - - {!isReadOnly && ( - - - - - )} - - )} - + {!isReadOnly && ( + + + + + + )} + + )} + + ); }; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx index b21b50586..cc38087dc 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ReviewApplications.tsx @@ -1,8 +1,17 @@ import React from 'react'; import { Stack, StackItem } from '@patternfly/react-core'; +import { TFunction } from 'react-i18next'; +import { AppType } from '@flightctl/types'; import { useTranslation } from '../../../../hooks/useTranslation'; -import { AppForm, getAppIdentifier, isInlineAppForm } from '../../../../types/deviceSpec'; +import { AppForm, getAppIdentifier, isImageAppForm } from '../../../../types/deviceSpec'; + +const getAppFormatLabel = (appType: AppType, t: TFunction) => { + if (appType === AppType.AppTypeQuadlet) { + return t('Quadlet'); + } + return t('Compose'); +}; const ReviewApplications = ({ apps }: { apps: AppForm[] }) => { const { t } = useTranslation(); @@ -13,10 +22,12 @@ const ReviewApplications = ({ apps }: { apps: AppForm[] }) => { return ( {apps.map((app, index) => { - const isInlineApp = isInlineAppForm(app); - const type = isInlineApp ? t('Inline') : t('Image based'); + const isImageApp = isImageAppForm(app); + const specType = isImageApp ? t('Image based') : t('Inline'); + const formatType = getAppFormatLabel(app.appType, t); + const type = `${specType} - ${formatType}`; let name: string = ''; - if (isInlineApp || app.name) { + if (!isImageApp || app.name) { name = app.name as string; } else if (app.image) { name = `${t('Unnamed')} (${app.image})`; diff --git a/libs/ui-components/src/components/form/validations.ts b/libs/ui-components/src/components/form/validations.ts index 256b0e785..aa4a4d644 100644 --- a/libs/ui-components/src/components/form/validations.ts +++ b/libs/ui-components/src/components/form/validations.ts @@ -2,12 +2,14 @@ import * as Yup from 'yup'; import { TFunction } from 'i18next'; import countBy from 'lodash/countBy'; +import { AppType } from '@flightctl/types'; import { FlightCtlLabel } from '../../types/extraTypes'; import { AppForm, AppSpecType, BatchForm, BatchLimitType, + ComposeAppForm, DisruptionBudgetForm, GitConfigTemplate, HttpConfigTemplate, @@ -15,6 +17,7 @@ import { InlineAppForm, InlineConfigTemplate, KubeSecretTemplate, + QuadletAppForm, RolloutPolicyForm, SpecConfigTemplate, SystemdUnitFormValue, @@ -22,9 +25,10 @@ import { getAppIdentifier, isGitConfigTemplate, isHttpConfigTemplate, - isInlineAppForm, + isImageAppForm, isInlineConfigTemplate, isKubeSecretTemplate, + isQuadletAppForm, } from '../../types/deviceSpec'; import { labelToString } from '../../utils/labels'; import { UpdateScheduleMode } from '../../utils/time'; @@ -64,6 +68,24 @@ const MAX_FILE_PATH_LENGTH = 253; const isInteger = (val: number | undefined) => val === undefined || Number.isInteger(val); +const validComposeFileNames = [ + 'podman-compose.yaml', + 'podman-compose.yml', + 'podman-compose.override.yaml', + 'docker-compose.yaml', + 'docker-compose.yml', + 'docker-compose.override.yaml', +]; +const validComposeFileNameDisplay = [ + '(podman|docker)-compose.yaml', + '(podman|docker)-compose.yml', + '(podman|docker)-compose.override.yaml', +].join(', '); + +// At least one of the following file types is required, and none of the unsupported ones are allowed +const supportedQuadletExtensions = ['.container', '.volume', '.network', '.image', '.pod']; +const unsupportedQuadletExtensions = ['.build', '.artifact', '.kube']; + export const getLabelValueValidations = (t: TFunction) => [ { key: 'labelValueStartAndEnd', message: t('Starts and ends with a letter or a number.') }, { @@ -303,86 +325,265 @@ const appVariablesSchema = (t: TFunction) => { }; const appSpecTypeSchema = (t: TFunction) => - Yup.string().oneOf([AppSpecType.INLINE, AppSpecType.OCI_IMAGE]).required(t('Application type is required')); + Yup.string().oneOf([AppSpecType.INLINE, AppSpecType.OCI_IMAGE]).required(t('Definition source is required')); + +// Common application name validation schema for inline applications (compose and quadlet) +const inlineAppNameSchema = (t: TFunction, appTypeName: string) => + Yup.string() + .required(t('Name is required for {{ appType }} applications.', { appType: appTypeName })) + .matches( + APPLICATION_NAME_REGEXP, + t('Use lowercase alphanumeric characters, or dash (-). Must start and end with an alphanumeric character.'), + ); + +// Common file path validation schema for inline applications +const inlineAppFilePathSchema = (t: TFunction) => + Yup.string() + .required(t('File path is required')) + .max( + MAX_FILE_PATH_LENGTH, + t('File path length cannot exceed {{ maxCharacters }} characters.', { + maxCharacters: MAX_FILE_PATH_LENGTH, + }), + ) + .matches( + relativePathRegex, + t('Application file path must be relative. It cannot be outside the application directory.'), + ); + +// Common file object schema for inline applications +const inlineAppFileSchema = (t: TFunction) => + Yup.array() + .of( + Yup.object().shape({ + content: Yup.string(), + path: inlineAppFilePathSchema(t), + }), + ) + .min(1, t('Application must include at least one file.')) + .required(); + +// Common test for unique file paths in inline applications +const uniqueFilePathsTest = + (t: TFunction) => (files: InlineAppForm['files'] | undefined, testContext: Yup.TestContext) => { + if (!files || files.length === 0) { + return true; + } + + const duplicateFilePaths = Object.entries(countBy(files.map((file) => file.path))) + .filter(([, count]) => { + return count > 1; + }) + .map(([filePath]) => filePath); + + if (duplicateFilePaths.length === 0) { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const duplicateIndex = ((testContext.parent.files || []) as InlineAppForm['files']).findIndex((file) => + duplicateFilePaths.includes(file.path), + ); + + if (duplicateIndex === -1) { + return true; + } + + return testContext.createError({ + path: `${testContext.path}[${duplicateIndex}].path`, + message: () => t('Each file of the same application must use different paths.'), + }); + }; + +const composeFileName = + (t: TFunction) => (files: ComposeAppForm['files'], testContext: Yup.TestContext) => { + const invalidFiles = files + .map((file, index) => { + if (!file.path) { + return null; + } + // Extract filename from relative path (get last part after slash, or use whole path if no slash) + const fileName = file.path.includes('/') ? file.path.split('/').pop() || file.path : file.path; + if (!validComposeFileNames.includes(fileName)) { + return index; + } + return null; + }) + .filter((index): index is number => index !== null); + + if (invalidFiles.length > 0) { + const firstInvalidIndex = invalidFiles[0]; + return testContext.createError({ + path: `${testContext.path}[${firstInvalidIndex}].path`, + message: () => + t('File name must be one of: {{ allowedFileNames }}', { + allowedFileNames: validComposeFileNameDisplay, + }), + }); + } + return true; + }; + +// Helper to extract file extension from a path +const getFileExtension = (path: string): string => { + const lastDot = path.lastIndexOf('.'); + return lastDot !== -1 ? path.substring(lastDot) : ''; +}; + +// Helper to check if a path is at root level (no slashes) +const isAtRoot = (path: string): boolean => { + return !path.includes('/'); +}; + +// Validation for quadlet applications: checks for unsupported types first, then requires at least one supported type +const quadletFileTypesValidation = + (t: TFunction) => (files: QuadletAppForm['files'], testContext: Yup.TestContext) => { + if (!files || files.length === 0) { + return true; // This is handled by the min(1) requirement + } + + // First, check for unsupported types (more specific error) + const invalidFiles = files + .map((file, index) => { + if (!file.path) { + return null; + } + const ext = getFileExtension(file.path); + if (unsupportedQuadletExtensions.includes(ext)) { + return index; + } + return null; + }) + .filter((index): index is number => index !== null); + + if (invalidFiles.length > 0) { + const firstInvalidIndex = invalidFiles[0]; + const invalidExt = getFileExtension(files[firstInvalidIndex].path); + return testContext.createError({ + path: `${testContext.path}[${firstInvalidIndex}].path`, + message: () => + t('Unsupported quadlet file type {{ extension }}. Supported types: {{ supportedTypes }}', { + extension: invalidExt, + supportedTypes: supportedQuadletExtensions.join(', '), + }), + }); + } + + // Then, check if there's at least one supported type + const hasSupportedType = files.some((file) => { + if (!file.path) { + return false; + } + const ext = getFileExtension(file.path); + return supportedQuadletExtensions.includes(ext); + }); + + if (!hasSupportedType) { + // Set error on the first file's path so it's visible in the UI + const firstFileWithPath = files.findIndex((file) => file.path); + const firstFileIndex = firstFileWithPath >= 0 ? firstFileWithPath : 0; + return testContext.createError({ + path: `${testContext.path}[${firstFileIndex}].path`, + message: () => + t('Quadlet application must include at least one of the following file types: {{ supportedTypes }}', { + supportedTypes: supportedQuadletExtensions.join(', '), + }), + }); + } + + return true; + }; + +// Validation for quadlet applications: quadlet files must be at root level +const quadletFilesAtRoot = + (t: TFunction) => (files: QuadletAppForm['files'], testContext: Yup.TestContext) => { + if (!files || files.length === 0) { + return true; + } + + const invalidFiles = files + .map((file, index) => { + if (!file.path) { + return null; + } + const ext = getFileExtension(file.path); + // Only check files with supported quadlet extensions + if (supportedQuadletExtensions.includes(ext) && !isAtRoot(file.path)) { + return index; + } + return null; + }) + .filter((index): index is number => index !== null); + + if (invalidFiles.length > 0) { + const firstInvalidIndex = invalidFiles[0]; + return testContext.createError({ + path: `${testContext.path}[${firstInvalidIndex}].path`, + message: () => t('Quadlet files must be at root level (no subdirectories)'), + }); + } + + return true; + }; export const validApplicationsSchema = (t: TFunction) => { return Yup.array() .of( Yup.lazy((value: AppForm) => { - if (isInlineAppForm(value)) { - return Yup.object().shape({ - specType: appSpecTypeSchema(t), - name: Yup.string() - .required(t('Name is required for inline applications.')) - .matches( - APPLICATION_NAME_REGEXP, - t( - 'Use lowercase alphanumeric characters, or dash (-). Must start and end with an alphanumeric character.', - ), + if (isImageAppForm(value)) { + // Image applications (can be either compose or quadlet) + return Yup.object().shape({ + specType: Yup.string() + .oneOf([AppSpecType.OCI_IMAGE]) + .required(t('Definition source must be image for this type of applications')), + appType: Yup.string() + .oneOf([AppType.AppTypeCompose, AppType.AppTypeQuadlet]) + .required(t('Application type is required')), + name: Yup.string().matches( + APPLICATION_NAME_REGEXP, + t( + 'Use lowercase alphanumeric characters, or dash (-). Must start and end with an alphanumeric character.', ), - files: Yup.array() - .of( - Yup.object().shape({ - content: Yup.string(), - path: Yup.string() - .required(t('File path is required')) - .max( - MAX_FILE_PATH_LENGTH, - t('File path length cannot exceed {{ maxCharacters }} characters.', { - maxCharacters: MAX_FILE_PATH_LENGTH, - }), - ) - .matches( - relativePathRegex, - t('Application file path must be relative. It cannot be outside the application directory.'), - ), - }), - ) - .required() - .min(1, t('Inline applications must include at least one file.')) - .test('unique-file-paths', (files: InlineAppForm['files'], testContext) => { - const duplicateFilePaths = Object.entries(countBy(files.map((file) => file.path))) - .filter(([, count]) => { - return count > 1; - }) - .map(([filePath]) => filePath); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const duplicateIndex = ((testContext.parent.files || []) as InlineAppForm['files']).findIndex((file) => - duplicateFilePaths.includes(file.path), - ); - if (duplicateIndex === -1) { - return true; - } + ), + image: Yup.string() + .required(t('Image is required.')) + .matches(APPLICATION_IMAGE_REGEXP, t('Application image includes invalid characters.')), + variables: appVariablesSchema(t), + }); + } - return testContext.createError({ - path: `${testContext.path}[${duplicateIndex}].path`, - message: () => t('Each file of the same application must use different paths.'), - }); - }), + // Inline quadlet applications + if (isQuadletAppForm(value)) { + return Yup.object().shape({ + specType: appSpecTypeSchema(t), + appType: Yup.string().oneOf([AppType.AppTypeQuadlet]).required(t('Application type is required')), + name: inlineAppNameSchema(t, 'quadlet'), + files: inlineAppFileSchema(t) + .test('unique-file-paths', uniqueFilePathsTest(t)) + .test('quadlet-file-types', quadletFileTypesValidation(t)) + .test('quadlet-files-at-root', quadletFilesAtRoot(t)), variables: appVariablesSchema(t), }); } - // Image applications - return Yup.object().shape({ + // Inline compose applications + return Yup.object().shape({ specType: appSpecTypeSchema(t), - name: Yup.string().matches( - APPLICATION_NAME_REGEXP, - t('Use lowercase alphanumeric characters, or dash (-). Must start and end with an alphanumeric character.'), - ), - image: Yup.string() - .required(t('Image is required.')) - .matches(APPLICATION_IMAGE_REGEXP, t('Application image includes invalid characters.')), + appType: Yup.string().oneOf([AppType.AppTypeCompose]).required(t('Application type is required')), + name: inlineAppNameSchema(t, 'compose'), + files: inlineAppFileSchema(t) + .test('unique-file-paths', uniqueFilePathsTest(t)) + .test('compose-file-name', composeFileName(t)), variables: appVariablesSchema(t), }); }), ) - .test('unique-app-ids', (apps: AppForm[] | undefined, testContext) => { - if (!apps?.length) { + .test('unique-app-ids', (appsValue, testContext) => { + if (!appsValue?.length) { return true; } + const apps = appsValue as AppForm[]; const appIds = apps.map(getAppIdentifier); const duplicateIds = Object.entries(countBy(appIds)) .filter(([, count]) => { diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index 47e68ae06..f7efe2565 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -1,13 +1,16 @@ import { + AppType, + ApplicationVolume, ConfigProviderSpec, DisruptionBudget, GitConfigProviderSpec, HttpConfigProviderSpec, ImageApplicationProviderSpec, - ImagePullPolicy, + ImageVolumeSource, InlineApplicationProviderSpec, InlineConfigProviderSpec, KubernetesSecretProviderSpec, + VolumeMount, } from '@flightctl/types'; import { FlightCtlLabel } from './extraTypes'; import { UpdateScheduleMode } from '../utils/time'; @@ -45,23 +48,30 @@ type InlineContent = { type AppBase = { specType: AppSpecType; - // appType: AppType - commented out for now, since it only accepts one value ("compose") + appType: AppType; name?: string; variables: { name: string; value: string }[]; - volumes?: { - name: string; - reference: string; - pullPolicy?: ImagePullPolicy; - }[]; + volumes?: ApplicationVolumeForm[]; }; -export type InlineAppForm = AppBase & { +export type ImageAppForm = AppBase & { + appType: AppType.AppTypeCompose | AppType.AppTypeQuadlet; + image: string; +}; + +export type InlineAppForm = ComposeAppForm | QuadletAppForm; + +export type ComposeAppForm = AppBase & { + appType: AppType.AppTypeCompose; name: string; // name can only be optional for image applications files: InlineContent[]; }; -export type ImageAppForm = AppBase & { - image: string; +// Technically it's the same as ComposeAppForm, with a different "appType" +export type QuadletAppForm = AppBase & { + appType: AppType.AppTypeQuadlet; + name: string; // name can only be optional for image applications + files: InlineContent[]; }; export const isGitConfigTemplate = (configTemplate: ConfigTemplate): configTemplate is GitConfigTemplate => @@ -90,6 +100,12 @@ export const isImageAppProvider = (app: ApplicationProviderSpecFixed): app is Im export const isImageAppForm = (app: AppBase): app is ImageAppForm => app.specType === AppSpecType.OCI_IMAGE; export const isInlineAppForm = (app: AppBase): app is InlineAppForm => app.specType === AppSpecType.INLINE; +export const isQuadletAppForm = (app: AppBase): app is QuadletAppForm => app.appType === AppType.AppTypeQuadlet; + +export type ApplicationVolumeForm = ApplicationVolume & { + image?: ImageVolumeSource; + mount?: VolumeMount; +}; const hasTemplateVariables = (str: string) => /{{.+?}}/.test(str);