From 7fd26084362d28e0a6f1c0a06178aa28324e919f Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Thu, 29 Jan 2026 11:56:11 +0100 Subject: [PATCH 1/2] OpenAPI schema for applications changed --- .eslintrc.js | 5 - libs/i18n/locales/en/translation.json | 8 +- libs/types/index.ts | 7 + libs/types/models/ApplicationProviderBase.ts | 16 + libs/types/models/ApplicationProviderSpec.ts | 20 +- libs/types/models/ApplicationUser.ts | 11 + libs/types/models/ComposeApplication.ts | 11 + libs/types/models/ContainerApplication.ts | 22 + .../models/ContainerApplicationProperties.ts | 17 + libs/types/models/HelmApplication.ts | 24 + .../models/ImageApplicationProviderSpec.ts | 24 +- .../models/InlineApplicationProviderSpec.ts | 5 +- libs/types/models/QuadletApplication.ts | 12 + .../EditDeviceWizard/deviceSpecUtils.ts | 768 ++++++++---------- .../steps/ApplicationContainerForm.tsx | 45 +- .../steps/ApplicationHelmForm.tsx | 16 +- .../steps/ApplicationImageForm.tsx | 18 +- .../steps/ApplicationInlineForm.tsx | 15 +- .../steps/ApplicationTemplates.tsx | 129 +-- .../steps/ApplicationVariablesForm.tsx | 87 ++ .../steps/ApplicationVolumeForm.tsx | 15 +- .../src/components/Fleet/CreateFleet/utils.ts | 4 +- .../src/components/form/validations.ts | 140 ++-- libs/ui-components/src/types/deviceSpec.ts | 173 ++-- libs/ui-components/src/types/extraTypes.ts | 14 +- 25 files changed, 760 insertions(+), 846 deletions(-) create mode 100644 libs/types/models/ApplicationProviderBase.ts create mode 100644 libs/types/models/ApplicationUser.ts create mode 100644 libs/types/models/ComposeApplication.ts create mode 100644 libs/types/models/ContainerApplication.ts create mode 100644 libs/types/models/ContainerApplicationProperties.ts create mode 100644 libs/types/models/HelmApplication.ts create mode 100644 libs/types/models/QuadletApplication.ts create mode 100644 libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVariablesForm.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 341eb9bc4..46c1123d3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -85,11 +85,6 @@ module.exports = { name: 'lodash', message: 'Import using full path `lodash/` instead', }, - { - name: '@flightctl/types', - importNames: ['ApplicationProviderSpec'], - message: 'Use FixedApplicationProviderSpec instead', - }, ], }, ], diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index df5347857..348fc6dc1 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -418,14 +418,16 @@ "Inline": "Inline", "Define application files directly in this interface (custom, one-off).": "Define application files directly in this interface (custom, one-off).", "The unique identifier for this application.": "The unique identifier for this application.", - "Variable {{ number }}": "Variable {{ number }}", - "Delete variable": "Delete variable", - "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", + "Environment variables": "Environment variables", + "Variable name": "Variable name", + "Variable value": "Variable value", + "Delete variable": "Delete variable", + "Add variable": "Add variable", "If not present": "If not present", "Always": "Always", "Never": "Never", diff --git a/libs/types/index.ts b/libs/types/index.ts index 07884afc2..9687613c8 100644 --- a/libs/types/index.ts +++ b/libs/types/index.ts @@ -8,11 +8,13 @@ export type { AbsolutePath } from './models/AbsolutePath'; export type { ApplicationContent } from './models/ApplicationContent'; export type { ApplicationEnvVars } from './models/ApplicationEnvVars'; export type { ApplicationPort } from './models/ApplicationPort'; +export type { ApplicationProviderBase } from './models/ApplicationProviderBase'; export type { ApplicationProviderSpec } from './models/ApplicationProviderSpec'; export type { ApplicationResourceLimits } from './models/ApplicationResourceLimits'; export type { ApplicationResources } from './models/ApplicationResources'; export { ApplicationsSummaryStatusType } from './models/ApplicationsSummaryStatusType'; export { ApplicationStatusType } from './models/ApplicationStatusType'; +export type { ApplicationUser } from './models/ApplicationUser'; export type { ApplicationVolume } from './models/ApplicationVolume'; export type { ApplicationVolumeProviderSpec } from './models/ApplicationVolumeProviderSpec'; export { ApplicationVolumeReclaimPolicy } from './models/ApplicationVolumeReclaimPolicy'; @@ -35,11 +37,14 @@ export type { CertificateSigningRequest } from './models/CertificateSigningReque export type { CertificateSigningRequestList } from './models/CertificateSigningRequestList'; export type { CertificateSigningRequestSpec } from './models/CertificateSigningRequestSpec'; export type { CertificateSigningRequestStatus } from './models/CertificateSigningRequestStatus'; +export type { ComposeApplication } from './models/ComposeApplication'; export type { Condition } from './models/Condition'; export type { ConditionBase } from './models/ConditionBase'; export { ConditionStatus } from './models/ConditionStatus'; export { ConditionType } from './models/ConditionType'; export type { ConfigProviderSpec } from './models/ConfigProviderSpec'; +export type { ContainerApplication } from './models/ContainerApplication'; +export type { ContainerApplicationProperties } from './models/ContainerApplicationProperties'; export type { CpuResourceMonitorSpec } from './models/CpuResourceMonitorSpec'; export type { CronExpression } from './models/CronExpression'; export type { CustomDeviceInfo } from './models/CustomDeviceInfo'; @@ -114,6 +119,7 @@ export type { FleetStatus } from './models/FleetStatus'; export type { GitConfigProviderSpec } from './models/GitConfigProviderSpec'; export type { GitHubIntrospectionSpec } from './models/GitHubIntrospectionSpec'; export type { GitRepoSpec } from './models/GitRepoSpec'; +export type { HelmApplication } from './models/HelmApplication'; export type { HookAction } from './models/HookAction'; export type { HookActionRun } from './models/HookActionRun'; export type { HookCondition } from './models/HookCondition'; @@ -157,6 +163,7 @@ export type { PatchRequest } from './models/PatchRequest'; export type { Percentage } from './models/Percentage'; export type { Permission } from './models/Permission'; export type { PermissionList } from './models/PermissionList'; +export type { QuadletApplication } from './models/QuadletApplication'; export type { ReferencedRepositoryUpdatedDetails } from './models/ReferencedRepositoryUpdatedDetails'; export type { RelativePath } from './models/RelativePath'; export type { Repository } from './models/Repository'; diff --git a/libs/types/models/ApplicationProviderBase.ts b/libs/types/models/ApplicationProviderBase.ts new file mode 100644 index 000000000..68e65e9dc --- /dev/null +++ b/libs/types/models/ApplicationProviderBase.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AppType } from './AppType'; +/** + * Common properties for all application types. + */ +export type ApplicationProviderBase = { + /** + * The application name must be 1–253 characters long, start with a letter or number, and contain no whitespace. + */ + name?: string; + appType: AppType; +}; + diff --git a/libs/types/models/ApplicationProviderSpec.ts b/libs/types/models/ApplicationProviderSpec.ts index 36259cf99..b92fa7605 100644 --- a/libs/types/models/ApplicationProviderSpec.ts +++ b/libs/types/models/ApplicationProviderSpec.ts @@ -2,19 +2,9 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { ApplicationEnvVars } from './ApplicationEnvVars'; -import type { AppType } from './AppType'; -import type { ImageApplicationProviderSpec } from './ImageApplicationProviderSpec'; -import type { InlineApplicationProviderSpec } from './InlineApplicationProviderSpec'; -export type ApplicationProviderSpec = (ApplicationEnvVars & { - /** - * The application name must be 1–253 characters long, start with a letter or number, and contain no whitespace. - */ - name?: string; - appType: AppType; - /** - * The username of the system user this application should be run under. This is not the same as the user within any containers of the application (if applicable). Defaults to the user that the agent runs as (generally root) if not specified. - */ - runAs?: string; -} & (ImageApplicationProviderSpec | InlineApplicationProviderSpec)); +import type { ComposeApplication } from './ComposeApplication'; +import type { ContainerApplication } from './ContainerApplication'; +import type { HelmApplication } from './HelmApplication'; +import type { QuadletApplication } from './QuadletApplication'; +export type ApplicationProviderSpec = (ComposeApplication | QuadletApplication | ContainerApplication | HelmApplication); diff --git a/libs/types/models/ApplicationUser.ts b/libs/types/models/ApplicationUser.ts new file mode 100644 index 000000000..bae22dbdf --- /dev/null +++ b/libs/types/models/ApplicationUser.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApplicationUser = { + /** + * The username of the system user this application should be run under. This is not the same as the user within any containers of the application (if applicable). Defaults to the user that the agent runs as (generally root) if not specified. + */ + runAs?: string; +}; + diff --git a/libs/types/models/ComposeApplication.ts b/libs/types/models/ComposeApplication.ts new file mode 100644 index 000000000..9b2ab5071 --- /dev/null +++ b/libs/types/models/ComposeApplication.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApplicationEnvVars } from './ApplicationEnvVars'; +import type { ApplicationProviderBase } from './ApplicationProviderBase'; +import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; +import type { ImageApplicationProviderSpec } from './ImageApplicationProviderSpec'; +import type { InlineApplicationProviderSpec } from './InlineApplicationProviderSpec'; +export type ComposeApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationVolumeProviderSpec & (ImageApplicationProviderSpec | InlineApplicationProviderSpec)); + diff --git a/libs/types/models/ContainerApplication.ts b/libs/types/models/ContainerApplication.ts new file mode 100644 index 000000000..a5c47f848 --- /dev/null +++ b/libs/types/models/ContainerApplication.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApplicationEnvVars } from './ApplicationEnvVars'; +import type { ApplicationPort } from './ApplicationPort'; +import type { ApplicationProviderBase } from './ApplicationProviderBase'; +import type { ApplicationResources } from './ApplicationResources'; +import type { ApplicationUser } from './ApplicationUser'; +import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; +export type ContainerApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationUser & ApplicationVolumeProviderSpec & { + /** + * Reference to the image for this container. + */ + image: string; + /** + * Port mappings. + */ + ports?: Array; + resources?: ApplicationResources; +}); + diff --git a/libs/types/models/ContainerApplicationProperties.ts b/libs/types/models/ContainerApplicationProperties.ts new file mode 100644 index 000000000..0dee8dd53 --- /dev/null +++ b/libs/types/models/ContainerApplicationProperties.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApplicationPort } from './ApplicationPort'; +import type { ApplicationResources } from './ApplicationResources'; +/** + * Properties for container application deployments. + */ +export type ContainerApplicationProperties = { + /** + * Port mappings. + */ + ports?: Array; + resources?: ApplicationResources; +}; + diff --git a/libs/types/models/HelmApplication.ts b/libs/types/models/HelmApplication.ts new file mode 100644 index 000000000..c56909739 --- /dev/null +++ b/libs/types/models/HelmApplication.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApplicationProviderBase } from './ApplicationProviderBase'; +export type HelmApplication = (ApplicationProviderBase & { + /** + * Reference to the chart for this helm application. + */ + image: string; + /** + * The target namespace for the application deployment. + */ + namespace?: string; + /** + * Configuration values for the application. Supports arbitrarily nested structures. + */ + values?: Record; + /** + * List of values files to apply during deployment. Files are relative paths and applied in array order before user-provided values. + */ + valuesFiles?: Array; +}); + diff --git a/libs/types/models/ImageApplicationProviderSpec.ts b/libs/types/models/ImageApplicationProviderSpec.ts index 94cfefa63..cdda8b5ef 100644 --- a/libs/types/models/ImageApplicationProviderSpec.ts +++ b/libs/types/models/ImageApplicationProviderSpec.ts @@ -2,30 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { ApplicationPort } from './ApplicationPort'; -import type { ApplicationResources } from './ApplicationResources'; -import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; -export type ImageApplicationProviderSpec = (ApplicationVolumeProviderSpec & { +export type ImageApplicationProviderSpec = { /** * Reference to the OCI image or artifact for the application package. */ image: string; - /** - * Kubernetes namespace for helm chart installation. Only applicable when appType is 'helm'. - */ - namespace?: string; - /** - * Helm values to pass during install/upgrade. Supports arbitrarily nested YAML structures. Only applicable when appType is 'helm'. - */ - values?: Record; - /** - * List of values files from within the chart to use during install/upgrade. Files are relative to chart root and are applied in array order before user-provided values. Only applicable when appType is 'helm'. - */ - valuesFiles?: Array; - /** - * Port mappings. - */ - ports?: Array; - resources?: ApplicationResources; -}); +}; diff --git a/libs/types/models/InlineApplicationProviderSpec.ts b/libs/types/models/InlineApplicationProviderSpec.ts index a9fd30854..63b58da0b 100644 --- a/libs/types/models/InlineApplicationProviderSpec.ts +++ b/libs/types/models/InlineApplicationProviderSpec.ts @@ -3,11 +3,10 @@ /* tslint:disable */ /* eslint-disable */ import type { ApplicationContent } from './ApplicationContent'; -import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; -export type InlineApplicationProviderSpec = (ApplicationVolumeProviderSpec & { +export type InlineApplicationProviderSpec = { /** * A list of application content. */ inline: Array; -}); +}; diff --git a/libs/types/models/QuadletApplication.ts b/libs/types/models/QuadletApplication.ts new file mode 100644 index 000000000..81a9d261e --- /dev/null +++ b/libs/types/models/QuadletApplication.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApplicationEnvVars } from './ApplicationEnvVars'; +import type { ApplicationProviderBase } from './ApplicationProviderBase'; +import type { ApplicationUser } from './ApplicationUser'; +import type { ApplicationVolumeProviderSpec } from './ApplicationVolumeProviderSpec'; +import type { ImageApplicationProviderSpec } from './ImageApplicationProviderSpec'; +import type { InlineApplicationProviderSpec } from './InlineApplicationProviderSpec'; +export type QuadletApplication = (ApplicationProviderBase & ApplicationEnvVars & ApplicationUser & ApplicationVolumeProviderSpec & (ImageApplicationProviderSpec | InlineApplicationProviderSpec)); + diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index 490c4f20a..79f3cd3ae 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -1,15 +1,19 @@ import yaml from 'js-yaml'; import { AppType, - // eslint-disable-next-line no-restricted-imports + ApplicationContent, ApplicationProviderSpec, ApplicationResourceLimits, ApplicationVolume, + ApplicationVolumeReclaimPolicy, + ComposeApplication, ConfigProviderSpec, + ContainerApplication, DeviceSpec, EncodingType, FileSpec, GitConfigProviderSpec, + HelmApplication, HttpConfigProviderSpec, ImageApplicationProviderSpec, ImageMountVolumeProviderSpec, @@ -18,42 +22,36 @@ import { InlineConfigProviderSpec, KubernetesSecretProviderSpec, PatchRequest, + QuadletApplication, } from '@flightctl/types'; import { AppForm, AppSpecType, ApplicationVolumeForm, - ComposeImageAppForm, - ComposeInlineAppForm, + ComposeAppForm, ConfigSourceProvider, ConfigType, GitConfigTemplate, - HelmImageAppForm, + HelmAppForm, HttpConfigTemplate, InlineConfigTemplate, + InlineFileForm, KubeSecretTemplate, - PortMapping, - QuadletImageAppForm, - QuadletInlineAppForm, + QuadletAppForm, RUN_AS_DEFAULT_USER, SingleContainerAppForm, SpecConfigTemplate, SystemdUnitFormValue, - isComposeImageAppForm, isGitConfigTemplate, isGitProviderSpec, - isHelmImageAppForm, isHttpConfigTemplate, isHttpProviderSpec, - isImageAppProvider, + isImageVariantApp, isInlineProviderSpec, + isInlineVariantApp, isKubeProviderSpec, isKubeSecretTemplate, - isQuadletImageAppForm, - isQuadletInlineAppForm, - isSingleContainerAppForm, } from '../../../types/deviceSpec'; -import { InlineApplicationFileFixed } from '../../../types/extraTypes'; const DEFAULT_INLINE_FILE_MODE = 420; // In Octal: 0644 const DEFAULT_INLINE_FILE_USER = 'root'; @@ -220,366 +218,359 @@ export const getDeviceSpecConfigPatches = ( return allPatches; }; -export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { - if (isHelmImageAppForm(app)) { - const data: ImageApplicationProviderSpec & ApplicationProviderSpec = { - name: app.name, - image: app.image, - appType: app.appType, - }; - if (app.namespace) { - data.namespace = app.namespace; - } - if (app.valuesYaml) { - try { - const values = yaml.load(app.valuesYaml) as Record; - if (values && Object.keys(values).length > 0) { - data.values = values; - } - } catch (error) { - throw new Error('Values content is not valid YAML.'); - } - } - const fileNames = app.valuesFiles.filter((file) => file && file.trim() !== ''); - if (fileNames.length > 0) { - data.valuesFiles = fileNames; - } - return data; - } - - const envVars = app.variables.reduce((acc, variable) => { - acc[variable.name] = variable.value; - return acc; - }, {}); - - const volumes = app.volumes?.map((v) => { - const volume: Partial = { - name: v.name || '', - }; - - if (v.imageRef) { - volume.image = { - reference: v.imageRef, - pullPolicy: v.imagePullPolicy || ImagePullPolicy.PullIfNotPresent, - }; - } - if (v.mountPath) { - volume.mount = { - path: v.mountPath, - }; - } - return volume as ApplicationVolume; - }); - - if (isSingleContainerAppForm(app)) { - const data: ImageApplicationProviderSpec & ApplicationProviderSpec = { - image: app.image, - appType: app.appType, - envVars, - volumes, - }; - if (app.name) { - data.name = app.name; - } - if (app.ports) { - data.ports = app.ports.map((p) => `${p.hostPort}:${p.containerPort}`); - } - // Removed fields must not appear in the resources object - const appLimits: ApplicationResourceLimits = {}; - if (app.limits?.cpu) { - appLimits.cpu = app.limits.cpu; - } - if (app.limits?.memory) { - appLimits.memory = app.limits.memory; - } - if (Object.keys(appLimits).length > 0) { - data.resources = { - limits: appLimits, - }; - } - data.runAs = app.runAs || RUN_AS_DEFAULT_USER; - return data; - } - - if (isQuadletImageAppForm(app) || isComposeImageAppForm(app)) { - const data: ApplicationProviderSpec = { - image: app.image, - appType: app.appType, - envVars, - volumes, - }; - if (app.name) { - data.name = app.name; - } - if (isQuadletImageAppForm(app) && app.runAs) { - data.runAs = app.runAs; - } - return data; - } - - // Inline applications (Quadlet or Compose) - const inlineData: ApplicationProviderSpec = { - name: app.name, - appType: app.appType, - inline: toAPIFiles(app.files), - envVars, - volumes, - }; - if (isQuadletInlineAppForm(app) && app.runAs) { - inlineData.runAs = app.runAs; - } - return inlineData; -}; - -const hasInlineApplicationChanged = ( - currentApp: InlineApplicationProviderSpec, - updatedApp: QuadletInlineAppForm | ComposeInlineAppForm, -) => { - if (currentApp.inline.length !== updatedApp.files.length) { - return true; - } - const filesChanged = currentApp.inline.some((file, index) => { - const updatedFile = updatedApp.files[index]; - const isCurrentBase64 = file.contentEncoding === EncodingType.EncodingBase64; +const haveInlineFilesChanged = (current: ApplicationContent[], updated: ApplicationContent[]): boolean => { + if (current.length !== updated.length) return true; + return current.some((file, index) => { + const other = updated[index]; + const aBase64 = file.contentEncoding === EncodingType.EncodingBase64; + const bBase64 = other.contentEncoding === EncodingType.EncodingBase64; return ( - (updatedFile.base64 || false) !== isCurrentBase64 || - updatedFile.path !== file.path || - updatedFile.content !== file.content + aBase64 !== bBase64 || (file.path || '') !== (other.path || '') || (file.content || '') !== (other.content || '') ); }); - if (filesChanged) { - return true; - } - - // Check runAs for Quadlet inline apps - if (isQuadletInlineAppForm(updatedApp)) { - const currentAppWithRunAs = currentApp as InlineApplicationProviderSpec & { runAs?: string }; - if ((currentAppWithRunAs.runAs || RUN_AS_DEFAULT_USER) !== (updatedApp.runAs || RUN_AS_DEFAULT_USER)) { - return true; - } - } - - return !areVolumesEqual(currentApp.volumes || [], updatedApp.volumes || []); }; -const areVolumesEqual = (currentVolumes: ApplicationVolume[], updatedFormVolumes: ApplicationVolumeForm[]): boolean => { - if (currentVolumes.length !== updatedFormVolumes.length) { - return false; - } - - return currentVolumes.every((currentVol, index) => { - const updatedFormVol = updatedFormVolumes[index]; - const currentFullVol = currentVol as ApplicationVolume & ImageMountVolumeProviderSpec; - - if (currentFullVol.name !== updatedFormVol.name) { - return false; - } +const haveEnvVarsChanged = (current: Record, updated: Record): boolean => { + const aKeys = Object.keys(current); + const bKeys = Object.keys(updated); + if (aKeys.length !== bKeys.length) return true; + return aKeys.some((key) => current[key] !== updated[key]); +}; - const currentImageRef = currentFullVol.image?.reference || ''; - const updatedImageRef = updatedFormVol?.imageRef || ''; - if (currentImageRef !== updatedImageRef) { - return false; - } +const haveVolumesChanged = (current: ApplicationVolume[], updated: ApplicationVolume[]): boolean => { + if (current.length !== updated.length) return true; + return current.some((currentVol, index) => { + const updatedVol = updated[index]; + if (currentVol.name !== updatedVol.name) return true; + if ( + (currentVol.reclaimPolicy || ApplicationVolumeReclaimPolicy.RETAIN) !== + (updatedVol.reclaimPolicy || ApplicationVolumeReclaimPolicy.RETAIN) + ) + return true; + const currentFull = currentVol as ApplicationVolume & ImageMountVolumeProviderSpec; + const updatedFull = updatedVol as ApplicationVolume & ImageMountVolumeProviderSpec; + const currentImageRef = currentFull.image?.reference || ''; + const updatedImageRef = updatedFull.image?.reference || ''; + if (currentImageRef !== updatedImageRef) return true; if (currentImageRef || updatedImageRef) { - const currentPullPolicy = currentFullVol.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent; - const updatedPullPolicy = updatedFormVol?.imagePullPolicy || ImagePullPolicy.PullIfNotPresent; - if (currentPullPolicy !== updatedPullPolicy) { - return false; - } - } - - const currentMountPath = currentFullVol.mount?.path || ''; - const updatedMountPath = updatedFormVol?.mountPath || ''; - if (currentMountPath !== updatedMountPath) { - return false; + if ( + (currentFull.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent) !== + (updatedFull.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent) + ) + return true; } - - return true; + return (currentFull.mount?.path || '') !== (updatedFull.mount?.path || ''); }); }; -const arePortsEqual = (currentPorts: string[], updatedPorts: PortMapping[]): boolean => { - if (currentPorts.length !== updatedPorts.length) { - return false; - } +const hasStringChanged = ( + current: string | undefined, + updated: string | undefined, + defaultValue: string = '', +): boolean => (current || defaultValue) !== (updated || defaultValue); - // Reordered ports will be considered as changed - return currentPorts.every((currentPort, index) => { - const updatedPort = updatedPorts[index]; - return currentPort === `${updatedPort.hostPort}:${updatedPort.containerPort}`; - }); +const havePortsChanged = (current: string[], updated: string[]): boolean => { + if (current.length !== updated.length) return true; + return current.some((port, index) => port !== updated[index]); }; -const areResourceLimitsEqual = ( - currentLimits: { cpu?: string; memory?: string } | undefined, - updatedLimits: { cpu?: string; memory?: string } | undefined, +const haveResourceLimitsChanged = ( + current: { cpu?: string; memory?: string } | undefined, + updated: { cpu?: string; memory?: string } | undefined, ): boolean => { - const currentCpu = currentLimits?.cpu || ''; - const updatedCpu = updatedLimits?.cpu || ''; - const currentMemory = currentLimits?.memory || ''; - const updatedMemory = updatedLimits?.memory || ''; - - return currentCpu === updatedCpu && currentMemory === updatedMemory; -}; - -const areEnvVariablesEqual = ( - currentVars: Record | undefined, - updatedFormVars: { name: string; value: string }[], + return (current?.cpu || '') !== (updated?.cpu || '') || (current?.memory || '') !== (updated?.memory || ''); +}; + +const haveValuesFilesChanged = (current: string[], updated: string[]): boolean => { + const a = current.filter((f) => f.trim() !== ''); + const b = updated.filter((f) => f.trim() !== ''); + if (a.length !== b.length) return true; + return a.some((file, i) => file !== b[i]); +}; + +const haveHelmValuesChanged = (current: Record, updated: Record): boolean => + JSON.stringify(current) !== JSON.stringify(updated); + +// Single container apps always have an image, and it doesn't have an inline variant +const hasContainerAppChanged = (current: ContainerApplication, updated: ContainerApplication): boolean => + hasStringChanged(current.name, updated.name) || + hasStringChanged(current.image, updated.image) || + havePortsChanged(current.ports || [], updated.ports || []) || + haveResourceLimitsChanged(current.resources?.limits, updated.resources?.limits) || + haveEnvVarsChanged(current.envVars || {}, updated.envVars || {}) || + hasStringChanged(current.runAs, updated.runAs, RUN_AS_DEFAULT_USER) || + haveVolumesChanged(current.volumes || [], updated.volumes || []); + +// Helm apps always have an image (chart), and it doesn't have an inline variant +const hasHelmAppChanged = (current: HelmApplication, updated: HelmApplication): boolean => + hasStringChanged(current.name, updated.name) || + hasStringChanged(current.image, updated.image) || + hasStringChanged(current.namespace, updated.namespace) || + haveValuesFilesChanged(current.valuesFiles || [], updated.valuesFiles || []) || + haveHelmValuesChanged(current.values || {}, updated.values || {}); + +const hasComposeAppChanged = ( + current: ComposeApplication, + updated: ComposeApplication, + specType: AppSpecType, ): boolean => { - const envVars = currentVars || {}; - if (Object.keys(envVars).length !== updatedFormVars.length) { - return false; - } - - return updatedFormVars.every((variable) => { - // Envvars may have "falsy" values (eg. number 0) when they are defined - return variable.name in envVars && envVars[variable.name] === variable.value; - }); -}; + const baseChanged = + hasStringChanged(current.name, updated.name) || + haveEnvVarsChanged(current.envVars || {}, updated.envVars || {}) || + haveVolumesChanged(current.volumes || [], updated.volumes || []); -const hasSingleContainerAppChanged = (currentApp: ApplicationProviderSpec, updatedApp: AppForm): boolean => { - if (!isSingleContainerAppForm(updatedApp)) { + if (baseChanged) { return true; } - const imageApp = currentApp as ImageApplicationProviderSpec & ApplicationProviderSpec; - if (imageApp.name !== updatedApp.name || imageApp.image !== updatedApp.image) { - return true; + if (specType === AppSpecType.OCI_IMAGE) { + return hasStringChanged( + (current as ImageApplicationProviderSpec).image, + (updated as ImageApplicationProviderSpec).image, + ); } + return haveInlineFilesChanged( + (current as InlineApplicationProviderSpec).inline, + (updated as InlineApplicationProviderSpec).inline, + ); +}; - if (!arePortsEqual(imageApp.ports || [], updatedApp.ports || [])) { +// Quadlet apps are currently the same as Compose apps, plus an optional "runAs" field. +const hasQuadletAppChanged = ( + current: QuadletApplication, + updated: QuadletApplication, + specType: AppSpecType, +): boolean => { + const baseChanged = hasComposeAppChanged(current, updated, specType); + if (baseChanged) { return true; } + return hasStringChanged(current.runAs, updated.runAs, RUN_AS_DEFAULT_USER); +}; - if (!areResourceLimitsEqual(imageApp.resources?.limits, updatedApp.limits)) { +const hasApplicationChanged = (current: ApplicationProviderSpec, updated: ApplicationProviderSpec): boolean => { + if (current.appType !== updated.appType) { return true; } - if (!areEnvVariablesEqual(imageApp.envVars, updatedApp.variables)) { + const currentSpectType = isImageVariantApp(current) ? AppSpecType.OCI_IMAGE : AppSpecType.INLINE; + const updatedSpectType = isImageVariantApp(updated) ? AppSpecType.OCI_IMAGE : AppSpecType.INLINE; + if (currentSpectType !== updatedSpectType) { return true; } + switch (current.appType) { + case AppType.AppTypeContainer: + return hasContainerAppChanged(current as ContainerApplication, updated as ContainerApplication); + case AppType.AppTypeHelm: + return hasHelmAppChanged(current as HelmApplication, updated as HelmApplication); + case AppType.AppTypeQuadlet: + return hasQuadletAppChanged(current as QuadletApplication, updated as QuadletApplication, currentSpectType); + case AppType.AppTypeCompose: + return hasComposeAppChanged(current as ComposeApplication, updated as ComposeApplication, currentSpectType); + } +}; - if ((imageApp.runAs || RUN_AS_DEFAULT_USER) !== (updatedApp.runAs || RUN_AS_DEFAULT_USER)) { - return true; +const variablesToEnvVars = (variables: { name: string; value: string }[]) => { + if (variables.length === 0) { + return undefined; } - return !areVolumesEqual(imageApp.volumes || [], updatedApp.volumes || []); + return variables.reduce( + (acc, v) => { + if (v.name) { + acc[v.name] = v.value || ''; + } + return acc; + }, + {} as Record, + ); }; -const hasApplicationChanged = (currentApp: ApplicationProviderSpec, updatedApp: AppForm): boolean => { - const isCurrentImageApp = isImageAppProvider(currentApp); - const currentAppSpecType = isCurrentImageApp ? AppSpecType.OCI_IMAGE : AppSpecType.INLINE; +/** + * Converts form volumes to API volumes, ignoring fields that are not allowed for the given app type. + * Quadlet/Compose apps --> can only be image volumes (mount is not allowed) + * Container apps --> can either be mount or image mount volumes + */ +const formVolumesToApi = (volumes: ApplicationVolumeForm[], appType: AppType): ApplicationVolume[] => { + return volumes.map((v) => { + const vol: Partial = { + name: v.name || '', + }; + if (v.imageRef) { + vol.image = { + reference: v.imageRef, + pullPolicy: v.imagePullPolicy || ImagePullPolicy.PullIfNotPresent, + }; + } + if (v.mountPath && appType === AppType.AppTypeContainer) { + vol.mount = { path: v.mountPath }; + } + return vol as ApplicationVolume; + }); +}; - // Check if application name changed, or it's a different type of application (either specType or appType) - if ( - currentAppSpecType !== updatedApp.specType || - currentApp.appType !== updatedApp.appType || - currentApp.name !== updatedApp.name - ) { - return true; - } +const formFilesToApi = (files: InlineFileForm[]) => + files.map((f) => ({ + path: f.path, + content: f.content || '', + contentEncoding: f.base64 ? EncodingType.EncodingBase64 : EncodingType.EncodingPlain, + })); - // The app is a single container application - if (isSingleContainerAppForm(updatedApp)) { - return hasSingleContainerAppChanged(currentApp, updatedApp); - } +const toFormFiles = (files: ApplicationContent[]) => + files.map((file) => ({ + path: file.path || '', + content: file.content || '', + base64: file.contentEncoding === EncodingType.EncodingBase64, + })); - // The app is a Helm application - if (isHelmImageAppForm(updatedApp)) { - const imageApp = currentApp as ImageApplicationProviderSpec; - if (imageApp.image !== updatedApp.image || imageApp.namespace !== updatedApp.namespace) { - return true; +const toApiHelmApp = (app: HelmAppForm): HelmApplication => { + const helmApp: HelmApplication = { + name: app.name, + image: app.image, + appType: app.appType, + }; + if (app.namespace) { + helmApp.namespace = app.namespace; + } + if (app.valuesYaml) { + try { + const values = yaml.load(app.valuesYaml) as Record; + if (values && Object.keys(values).length > 0) helmApp.values = values; + } catch { + // leave values unset on invalid YAML } + } + const fileNames = (app.valuesFiles || []).filter((f) => f.trim() !== ''); + if (fileNames.length > 0) { + helmApp.valuesFiles = fileNames; + } + return helmApp; +}; - // Compare valuesFiles arrays - const currentValuesFiles = (imageApp.valuesFiles || []).filter((file) => file !== ''); - const updatedValuesFiles = updatedApp.valuesFiles.filter((file) => file !== ''); - if (currentValuesFiles.length !== updatedValuesFiles.length) { - return true; - } - if (!currentValuesFiles.every((file, index) => file === updatedValuesFiles[index])) { - return true; +const toApiContainerApp = (app: SingleContainerAppForm): ContainerApplication => { + const containerApp: ContainerApplication = { + name: app.name, + image: app.image, + appType: app.appType, + runAs: app.runAs || RUN_AS_DEFAULT_USER, + envVars: variablesToEnvVars(app.variables || []), + volumes: formVolumesToApi(app.volumes || [], AppType.AppTypeContainer), + }; + if (app.ports.length > 0) { + containerApp.ports = app.ports.map((p) => `${p.hostPort}:${p.containerPort}`); + } + + const cpu = app.cpuLimit; + const memory = app.memoryLimit; + if (cpu || memory) { + const limits: ApplicationResourceLimits = {}; + if (cpu) { + limits.cpu = cpu; } - const updatedValues = yaml.load(updatedApp.valuesYaml || ' ') as Record; - if (JSON.stringify(imageApp.values || {}) !== JSON.stringify(updatedValues)) { - return true; + if (memory) { + limits.memory = memory; } - return false; - } - if (!areEnvVariablesEqual(currentApp.envVars, updatedApp.variables)) { - return true; + containerApp.resources = { limits }; } + return containerApp; +}; - // The app is an image application (Quadlet/Compose image apps) - if (isCurrentImageApp) { - const imageApp = currentApp as ImageApplicationProviderSpec; - const updatedImageApp = updatedApp as QuadletImageAppForm | ComposeImageAppForm; - if (imageApp.image !== updatedImageApp.image) { - return true; - } +const toApiComposeApp = (app: ComposeAppForm): ComposeApplication => { + const formApp: Partial = { + name: app.name, + appType: app.appType, + envVars: variablesToEnvVars(app.variables || []), + volumes: formVolumesToApi(app.volumes || [], app.appType), + }; + if (app.specType === AppSpecType.OCI_IMAGE) { + (formApp as ImageApplicationProviderSpec).image = app.image; + } else { + (formApp as InlineApplicationProviderSpec).inline = formFilesToApi(app.files); + } + return formApp as ComposeApplication; +}; - // Check runAs for Quadlet image apps - if (isQuadletImageAppForm(updatedApp)) { - if ((currentApp.runAs || RUN_AS_DEFAULT_USER) !== (updatedApp.runAs || RUN_AS_DEFAULT_USER)) { - return true; - } - } +// Quadlet apps are currently the same as Compose apps, plus an optional "runAs" field. +const toApiQuadletApp = (app: QuadletAppForm): QuadletApplication => { + const baseApp = toApiComposeApp(app); + return { ...baseApp, appType: AppType.AppTypeQuadlet, runAs: app.runAs || RUN_AS_DEFAULT_USER }; +}; - return !areVolumesEqual(imageApp.volumes || [], updatedApp.volumes || []); +export const toApiApplication = (app: AppForm): ApplicationProviderSpec => { + switch (app.appType) { + case AppType.AppTypeHelm: + return toApiHelmApp(app as HelmAppForm); + case AppType.AppTypeContainer: + return toApiContainerApp(app as SingleContainerAppForm); + case AppType.AppTypeQuadlet: + return toApiQuadletApp(app as QuadletAppForm); + case AppType.AppTypeCompose: + return toApiComposeApp(app as ComposeAppForm); + default: + throw new Error('Unknown application type'); } +}; - // The app must be an inline application - return hasInlineApplicationChanged( - currentApp as InlineApplicationProviderSpec, - updatedApp as QuadletInlineAppForm | ComposeInlineAppForm, - ); +const toFormVariables = (envVars: Record): { name: string; value: string }[] => + Object.entries(envVars).map(([name, value]) => ({ name, value: value || '' })); + +const toFormVolumes = (volumes?: ApplicationVolume[]): ApplicationVolumeForm[] => { + if (!volumes) return []; + return volumes.map((vol) => { + const fullVolume = vol as ApplicationVolume & ImageMountVolumeProviderSpec; + return { + name: fullVolume.name, + imageRef: fullVolume.image?.reference || '', + mountPath: fullVolume.mount?.path || '', + imagePullPolicy: fullVolume.image?.pullPolicy || ImagePullPolicy.PullIfNotPresent, + }; + }); +}; + +const toFormApps = (app: ApplicationProviderSpec): AppForm => { + switch (app.appType) { + case AppType.AppTypeContainer: + return toContainerAppForm(app as ContainerApplication); + case AppType.AppTypeHelm: + return toHelmAppForm(app as HelmApplication); + case AppType.AppTypeQuadlet: + return toQuadletAppForm(app as QuadletApplication); + case AppType.AppTypeCompose: + return toComposeAppForm(app as ComposeApplication); + default: + throw new Error('Unknown application type'); + } }; export const getApplicationPatches = ( basePath: string, currentApps: ApplicationProviderSpec[], updatedApps: AppForm[], -) => { +): PatchRequest => { const patches: PatchRequest = []; - const currentLen = currentApps.length; const newLen = updatedApps.length; if (currentLen === 0 && newLen > 0) { - // First apps(s) have been added - patches.push({ - path: `${basePath}/applications`, - op: 'add', - value: updatedApps.map(toAPIApplication), - }); + patches.push({ path: `${basePath}/applications`, op: 'add', value: updatedApps.map(toApiApplication) }); } else if (currentLen > 0 && newLen === 0) { - // Last app(s) have been removed - patches.push({ - path: `${basePath}/applications`, - op: 'remove', - }); + patches.push({ path: `${basePath}/applications`, op: 'remove' }); } else if (currentLen !== newLen) { - // Array length changed, need to replace entire array - patches.push({ - path: `${basePath}/applications`, - op: 'replace', - value: updatedApps.map(toAPIApplication), - }); + patches.push({ path: `${basePath}/applications`, op: 'replace', value: updatedApps.map(toApiApplication) }); } else { - // Apps length has not changed. We only PATCH the applications that have actually changed currentApps.forEach((currentApp, index) => { const updatedApp = updatedApps[index]; - if (hasApplicationChanged(currentApp, updatedApp)) { + const updatedApi = toApiApplication(updatedApp); + if (hasApplicationChanged(currentApp, updatedApi)) { patches.push({ path: `${basePath}/applications/${index}`, op: 'replace', - value: toAPIApplication(updatedApp), + value: updatedApi, }); } }); } - return patches; }; @@ -635,45 +626,7 @@ export const getApiConfig = (ct: SpecConfigTemplate): ConfigSourceProvider => { }; }; -const getAppFormVariables = (envVars: Record | undefined) => - Object.entries(envVars || {}).map(([varName, varValue]) => ({ name: varName, value: varValue })); - -const toFormFiles = (files: InlineApplicationFileFixed[]) => { - return files.map((file) => ({ - path: file.path || '', - content: file.content || '', - base64: file.contentEncoding === EncodingType.EncodingBase64, - })); -}; - -const toAPIFiles = (files: ComposeInlineAppForm['files']) => { - return files.map((file) => ({ - path: file.path, - content: file.content || '', - contentEncoding: file.base64 ? EncodingType.EncodingBase64 : EncodingType.EncodingPlain, - })); -}; - -const convertVolumesToForm = (volumes?: ApplicationVolume[]) => { - if (!volumes) return []; - return volumes.map((vol) => { - const fullVolume = vol as ApplicationVolume & ImageMountVolumeProviderSpec; - const volForm: ApplicationVolumeForm = { - name: fullVolume.name, - imageRef: fullVolume.image?.reference || '', - mountPath: fullVolume.mount?.path || '', - }; - // Only set imagePullPolicy if there's an image - if (fullVolume.image) { - volForm.imagePullPolicy = fullVolume.image.pullPolicy || ImagePullPolicy.PullIfNotPresent; - } - return volForm; - }); -}; - -const createContainerApp = ( - containerApp: (ApplicationProviderSpec & ImageApplicationProviderSpec) | undefined, -): SingleContainerAppForm => { +const toContainerAppForm = (containerApp: ContainerApplication | undefined): SingleContainerAppForm => { const ports = containerApp?.ports?.map((portString) => { const [hostPort, containerPort] = portString.split(':'); @@ -681,136 +634,93 @@ const createContainerApp = ( }) || []; const limits = containerApp?.resources?.limits; + return { appType: AppType.AppTypeContainer, specType: AppSpecType.OCI_IMAGE, name: containerApp?.name || '', image: containerApp?.image || '', - variables: getAppFormVariables(containerApp?.envVars), - volumes: convertVolumesToForm(containerApp?.volumes), + variables: toFormVariables(containerApp?.envVars || {}), + volumes: toFormVolumes(containerApp?.volumes), ports, - limits: limits - ? { - cpu: limits.cpu || '', - memory: limits.memory || '', - } - : undefined, + cpuLimit: limits?.cpu || '', + memoryLimit: limits?.memory || '', runAs: containerApp?.runAs || RUN_AS_DEFAULT_USER, }; }; -const createHelmApp = ( - helmApp: (ApplicationProviderSpec & ImageApplicationProviderSpec) | undefined, -): HelmImageAppForm => { +const toHelmAppForm = (helmApp: HelmApplication | undefined): HelmAppForm => { + // We want to always show at least one values file field, even when no files have been added yet. const values = helmApp?.values || {}; + const valuesFiles = helmApp?.valuesFiles?.length ? helmApp.valuesFiles : ['']; + const valuesYaml = Object.keys(values || {}).length > 0 ? yaml.dump(values) : ''; + return { appType: AppType.AppTypeHelm, specType: AppSpecType.OCI_IMAGE, name: helmApp?.name || '', image: helmApp?.image || '', - namespace: helmApp?.namespace, - valuesYaml: Object.keys(values).length > 0 ? yaml.dump(values) : '', - valuesFiles: helmApp?.valuesFiles || [''], - }; -}; - -const createQuadletImageApp = ( - quadletApp: (ApplicationProviderSpec & ImageApplicationProviderSpec) | undefined, -): QuadletImageAppForm => { - return { - appType: AppType.AppTypeQuadlet, - specType: AppSpecType.OCI_IMAGE, - name: quadletApp?.name || '', - image: quadletApp?.image || '', - variables: getAppFormVariables(quadletApp?.envVars), - volumes: convertVolumesToForm(quadletApp?.volumes), - runAs: quadletApp?.runAs || RUN_AS_DEFAULT_USER, + namespace: helmApp?.namespace || '', + valuesYaml, + valuesFiles, }; }; -const createQuadletInlineApp = ( - quadletApp: (ApplicationProviderSpec & InlineApplicationProviderSpec) | undefined, -): QuadletInlineAppForm => { - return { - appType: AppType.AppTypeQuadlet, - specType: AppSpecType.INLINE, - name: quadletApp?.name || '', - files: toFormFiles(quadletApp?.inline || []), - variables: getAppFormVariables(quadletApp?.envVars), - volumes: convertVolumesToForm(quadletApp?.volumes), - runAs: quadletApp?.runAs || RUN_AS_DEFAULT_USER, - }; -}; - -const createComposeImageApp = ( - composeApp: (ApplicationProviderSpec & ImageApplicationProviderSpec) | undefined, -): ComposeImageAppForm => { - return { +const toComposeAppForm = (app: ComposeApplication | undefined): ComposeAppForm => { + const isInlineVariant = app && isInlineVariantApp(app); + const specType = isInlineVariant ? AppSpecType.INLINE : AppSpecType.OCI_IMAGE; + const formApp: Partial = { appType: AppType.AppTypeCompose, - specType: AppSpecType.OCI_IMAGE, - name: composeApp?.name || '', - image: composeApp?.image || '', - variables: getAppFormVariables(composeApp?.envVars), - volumes: convertVolumesToForm(composeApp?.volumes), + specType, + name: app?.name || '', + variables: toFormVariables(app?.envVars || {}), + volumes: toFormVolumes(app?.volumes), }; + + // We want to have both fields initialized for the formik form + if (isInlineVariant) { + formApp.files = toFormFiles(app?.inline || []); + formApp.image = ''; + } else { + formApp.image = app?.image || ''; + formApp.files = []; + } + return formApp as ComposeAppForm; }; -const createComposeInlineApp = ( - composeApp: (ApplicationProviderSpec & InlineApplicationProviderSpec) | undefined, -): ComposeInlineAppForm => { +const toQuadletAppForm = (app: QuadletApplication | undefined): QuadletAppForm => { + const baseApp = toComposeAppForm(app); return { - appType: AppType.AppTypeCompose, - specType: AppSpecType.INLINE, - name: composeApp?.name || '', - files: toFormFiles(composeApp?.inline || []), - variables: getAppFormVariables(composeApp?.envVars), - volumes: convertVolumesToForm(composeApp?.volumes), + ...baseApp, + appType: AppType.AppTypeQuadlet, + runAs: app?.runAs || RUN_AS_DEFAULT_USER, }; }; -export const createInitialAppForm = (appType: AppType, specType: AppSpecType, name: string = ''): AppForm => { +export const createInitialAppForm = (appType: AppType, name: string = ''): AppForm => { let app: AppForm; switch (appType) { case AppType.AppTypeContainer: - app = createContainerApp(undefined); + app = toContainerAppForm(undefined); break; case AppType.AppTypeHelm: - app = createHelmApp(undefined); + app = toHelmAppForm(undefined); break; case AppType.AppTypeQuadlet: - app = specType === AppSpecType.OCI_IMAGE ? createQuadletImageApp(undefined) : createQuadletInlineApp(undefined); + app = toQuadletAppForm(undefined); break; case AppType.AppTypeCompose: - app = specType === AppSpecType.OCI_IMAGE ? createComposeImageApp(undefined) : createComposeInlineApp(undefined); + app = toComposeAppForm(undefined); break; + default: + throw new Error('Unknown application type'); } app.name = name; return app; }; -export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => { - const apps = deviceSpec?.applications || []; - return apps.map((app) => { - if (!app.appType) { - throw new Error('Application appType is required'); - } - - switch (app.appType) { - case AppType.AppTypeContainer: - return createContainerApp(app as ApplicationProviderSpec & ImageApplicationProviderSpec); - case AppType.AppTypeHelm: - return createHelmApp(app as ApplicationProviderSpec & ImageApplicationProviderSpec); - case AppType.AppTypeQuadlet: - return isImageAppProvider(app) - ? createQuadletImageApp(app) - : createQuadletInlineApp(app as ApplicationProviderSpec & InlineApplicationProviderSpec & { runAs?: string }); - case AppType.AppTypeCompose: - return isImageAppProvider(app) - ? createComposeImageApp(app) - : createComposeInlineApp(app as ApplicationProviderSpec & InlineApplicationProviderSpec); - } - }); -}; +export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => + (deviceSpec?.applications || []).map(toFormApps); export const getSystemdUnitsValues = (deviceSpec?: DeviceSpec): SystemdUnitFormValue[] => { return ( diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx index ce2296d75..8ad0bf80f 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useField } from 'formik'; +import { useField, useFormikContext } from 'formik'; import { Button, FormGroup, Grid, Label, LabelGroup, Split, SplitItem, TextInput } from '@patternfly/react-core'; import { ArrowRightIcon } from '@patternfly/react-icons/dist/js/icons/arrow-right-icon'; @@ -13,18 +13,12 @@ import ApplicationIntegritySettings from './ApplicationIntegritySettings'; import './ApplicationContainerForm.css'; -const ApplicationContainerForm = ({ - app, - index, - isReadOnly, -}: { - app: SingleContainerAppForm; - index: number; - isReadOnly?: boolean; -}) => { +const ApplicationContainerForm = ({ index, isReadOnly }: { index: number; isReadOnly?: boolean }) => { const { t } = useTranslation(); const appFieldName = `applications[${index}]`; - const [{ value: ports }, , { setValue: setPorts, setTouched }] = useField(`${appFieldName}.ports`); + const [{ value: app }] = useField(`${appFieldName}`); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const ports = React.useMemo(() => app.ports || [], [app.ports]); const [hostPort, setHostPort] = React.useState(''); const [containerPort, setContainerPort] = React.useState(''); @@ -33,7 +27,7 @@ const ApplicationContainerForm = ({ const [editingPortIndex, setEditingPortIndex] = React.useState(null); const [editingPortError, setEditingPortError] = React.useState(undefined); - const canAddPorts = !editingPortError && isValidPortMapping(hostPort, containerPort, ports || []) && !isReadOnly; + const canAddPorts = !editingPortError && isValidPortMapping(hostPort, containerPort, ports) && !isReadOnly; const validatePort = (port: string): string | undefined => { const error = validatePortNumber(port, t); @@ -41,7 +35,7 @@ const ApplicationContainerForm = ({ return error; } // Check for duplicate if both ports match the number pattern - if (isDuplicatePortMapping(port, containerPort, ports || [])) { + if (isDuplicatePortMapping(port, containerPort, ports)) { return t('This port mapping already exists'); } return undefined; @@ -51,13 +45,13 @@ const ApplicationContainerForm = ({ const containerPortError = containerPortTouched ? validatePort(containerPort) : undefined; const updatePorts = async (newPorts: PortMapping[]) => { - await setPorts(newPorts, true); - setTouched(true); + await setFieldValue(`${appFieldName}.ports`, newPorts, true); + setFieldTouched(`${appFieldName}.ports`, true); }; const onAddPort = () => { - if (isValidPortMapping(hostPort, containerPort, ports || [])) { - updatePorts([...(ports || []), { hostPort, containerPort }]); + if (isValidPortMapping(hostPort, containerPort, ports)) { + updatePorts([...ports, { hostPort, containerPort }]); setHostPort(''); setContainerPort(''); setHostPortTouched(false); @@ -68,7 +62,7 @@ const ApplicationContainerForm = ({ }; const onDeletePort = async (index: number) => { - const newPorts = [...(ports || [])]; + const newPorts = [...ports]; newPorts.splice(index, 1); await updatePorts(newPorts); // Clear error state if the deleted port was being edited @@ -107,7 +101,7 @@ const ApplicationContainerForm = ({ } // Check for duplicates, excluding the current port being edited - const otherPorts = excludeIndex !== undefined ? (ports || []).filter((_, i) => i !== excludeIndex) : ports || []; + const otherPorts = excludeIndex !== undefined ? ports.filter((_, i) => i !== excludeIndex) : ports; if (isDuplicatePortMapping(hostPortValue, containerPortValue, otherPorts)) { return t('This port mapping already exists'); } @@ -127,7 +121,7 @@ const ApplicationContainerForm = ({ return; } - const newPorts = [...(ports || [])]; + const newPorts = [...ports]; newPorts[index] = { hostPort: newHostPort || '', containerPort: newContainerPort || '' }; await updatePorts(newPorts); @@ -146,7 +140,7 @@ const ApplicationContainerForm = ({ // Clear error state if the editing index becomes invalid (e.g., port was deleted externally) React.useEffect(() => { - if (editingPortIndex !== null && (!ports || editingPortIndex >= ports.length)) { + if (editingPortIndex !== null && editingPortIndex >= ports.length) { setEditingPortIndex(null); setEditingPortError(undefined); } @@ -172,7 +166,6 @@ const ApplicationContainerForm = ({ @@ -271,8 +264,8 @@ const ApplicationContainerForm = ({ > { +const ApplicationHelmForm = ({ index, isReadOnly }: { index: number; isReadOnly?: boolean }) => { const { t } = useTranslation(); const appFieldName = `applications[${index}]`; - const [{ value: valuesFiles }] = useField>(`${appFieldName}.valuesFiles`); + const [{ value: app }] = useField(`${appFieldName}`); + const valuesFiles = app.valuesFiles || []; const canAddValuesFile = valuesFiles && valuesFiles.every((file) => file && file.trim() !== ''); return ( @@ -48,7 +41,6 @@ const ApplicationHelmForm = ({ diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx index 53409ccd5..4f857ee42 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx @@ -6,17 +6,8 @@ import TextField from '../../../form/TextField'; import LearnMoreLink from '../../../common/LearnMoreLink'; import { useTranslation } from '../../../../hooks/useTranslation'; import { useAppLinks } from '../../../../hooks/useAppLinks'; -import { ComposeImageAppForm, QuadletImageAppForm } from '../../../../types/deviceSpec'; -const ApplicationImageForm = ({ - app, - index, - isReadOnly, -}: { - app: QuadletImageAppForm | ComposeImageAppForm; - index: number; - isReadOnly?: boolean; -}) => { +const ApplicationImageForm = ({ index, isReadOnly }: { index: number; isReadOnly?: boolean }) => { const { t } = useTranslation(); const createAppLink = useAppLinks('createApp'); @@ -32,12 +23,7 @@ const ApplicationImageForm = ({ } isRequired > - + ); diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx index 7ea6b3a5a..ed41b085e 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx @@ -9,12 +9,12 @@ import CheckboxField from '../../../form/CheckboxField'; import UploadField from '../../../form/UploadField'; import TextField from '../../../form/TextField'; import ExpandableFormSection from '../../../form/ExpandableFormSection'; -import { ComposeInlineAppForm, QuadletInlineAppForm } from '../../../../types/deviceSpec'; +import { InlineFileForm } from '../../../../types/deviceSpec'; const MAX_INLINE_FILE_SIZE_BYTES = 1024 * 1024; type InlineApplicationFileFormProps = { - file: (QuadletInlineAppForm | ComposeInlineAppForm)['files'][0]; + file: InlineFileForm; fileFieldName: string; fileIndex: number; isReadOnly?: boolean; @@ -55,17 +55,18 @@ const InlineApplicationFileForm = ({ file, fileIndex, fileFieldName, isReadOnly }; const ApplicationInlineForm = ({ - app, + files, index, isReadOnly, }: { - app: QuadletInlineAppForm | ComposeInlineAppForm; + files: InlineFileForm[]; index: number; isReadOnly?: boolean; }) => { const { t } = useTranslation(); - if (isReadOnly && !app.files?.length) { + const fileList = files || []; + if (isReadOnly && fileList.length === 0) { return null; } @@ -74,7 +75,7 @@ const ApplicationInlineForm = ({ {({ push, remove }) => ( <> - {app.files?.map((file, fileIndex) => { + {fileList.map((file, fileIndex) => { const fieldName = `applications[${index}].files[${fileIndex}]`; return ( @@ -86,7 +87,7 @@ const ApplicationInlineForm = ({ isReadOnly={isReadOnly} /> - {!isReadOnly && app.files.length > 1 && ( + {!isReadOnly && fileList.length > 1 && ( - - )} - - )} - + )} @@ -287,7 +214,7 @@ const ApplicationTemplates = ({ isReadOnly }: { isReadOnly?: boolean }) => { icon={} iconPosition="start" onClick={() => { - push(createInitialAppForm(AppType.AppTypeContainer, AppSpecType.OCI_IMAGE)); + push(createInitialAppForm(AppType.AppTypeContainer)); }} > {t('Add application')} diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVariablesForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVariablesForm.tsx new file mode 100644 index 000000000..9158f1fef --- /dev/null +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVariablesForm.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { FieldArray, useField } from 'formik'; +import { Button, FormGroup, FormSection, Grid, Split, SplitItem } from '@patternfly/react-core'; +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 { VariablesForm } from '../../../../types/deviceSpec'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import TextField from '../../../form/TextField'; +import ErrorHelperText from '../../../form/FieldHelperText'; + +type ApplicationVariablesFormProps = { + appFieldName: string; + isReadOnly?: boolean; +}; + +const ApplicationVariablesForm = ({ appFieldName, isReadOnly }: ApplicationVariablesFormProps) => { + const { t } = useTranslation(); + const [{ value: variables = [] }, { error }] = useField(`${appFieldName}.variables`); + + // @ts-expect-error Formik error object includes "variables" + const appVarsError = typeof error?.variables === 'string' ? (error.variables as string) : undefined; + + return ( + + + {({ push, remove }) => ( + <> + {variables.map((_variable, variableIndex) => { + const variableFieldName = `${appFieldName}.variables[${variableIndex}]`; + return ( + + + + + + + + + + + + + {!isReadOnly && ( + + + + )} + + )} + + + ); +}; + +export default ApplicationVariablesForm; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVolumeForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVolumeForm.tsx index 083407c24..0ede5e89a 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVolumeForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationVolumeForm.tsx @@ -18,7 +18,6 @@ import './ApplicationVolumeForm.css'; type ApplicationVolumeFormProps = { appFieldName: string; - volumes: VolumeFormType[]; isReadOnly?: boolean; isSingleContainerApp?: boolean; }; @@ -31,16 +30,15 @@ const getPullPolicyOptions = (t: TFunction) => ({ const ApplicationVolumeForm = ({ appFieldName, - volumes, isReadOnly, isSingleContainerApp = false, }: ApplicationVolumeFormProps) => { const { t } = useTranslation(); - const [, { error }] = useField(`${appFieldName}.volumes`); - const pullPolicyOptions = React.useMemo(() => getPullPolicyOptions(t), [t]); + const [{ value: volumes = [] }, { error }] = useField(`${appFieldName}.volumes`); const volumesError = typeof error === 'string' ? error : undefined; + return ( @@ -126,15 +124,12 @@ const ApplicationVolumeForm = ({ icon={} iconPosition="start" onClick={() => { - const emptyVolume: VolumeFormType = { + push({ name: '', imageRef: '', imagePullPolicy: ImagePullPolicy.PullIfNotPresent, - }; - if (isSingleContainerApp) { - emptyVolume.mountPath = ''; - } - push(emptyVolume); + mountPath: '', + }); }} > {t('Add volume')} diff --git a/libs/ui-components/src/components/Fleet/CreateFleet/utils.ts b/libs/ui-components/src/components/Fleet/CreateFleet/utils.ts index e5fc5b618..b8626ddc4 100644 --- a/libs/ui-components/src/components/Fleet/CreateFleet/utils.ts +++ b/libs/ui-components/src/components/Fleet/CreateFleet/utils.ts @@ -34,7 +34,7 @@ import { getDeviceSpecConfigPatches, getSystemdUnitsValues, hasMicroshiftRegistrationConfig, - toAPIApplication, + toApiApplication, } from '../../Device/EditDeviceWizard/deviceSpecUtils'; import { getDisruptionBudgetValues, getRolloutPolicyValues, getUpdatePolicyValues } from './fleetSpecUtils'; import { FleetFormValues, UpdatePolicyForm } from '../../../types/deviceSpec'; @@ -197,7 +197,7 @@ export const getFleetResource = (values: FleetFormValues): Fleet => { spec: { os: values.osImage ? { image: values.osImage || '' } : undefined, config: values.configTemplates.map(getApiConfig), - applications: values.applications.map(toAPIApplication), + applications: values.applications.map(toApiApplication), ...systemdPatterns, }, }, diff --git a/libs/ui-components/src/components/form/validations.ts b/libs/ui-components/src/components/form/validations.ts index c6b16f713..6f9cd1733 100644 --- a/libs/ui-components/src/components/form/validations.ts +++ b/libs/ui-components/src/components/form/validations.ts @@ -10,31 +10,25 @@ import { AppSpecType, BatchForm, BatchLimitType, - ComposeImageAppForm, - ComposeInlineAppForm, + ComposeAppForm, DisruptionBudgetForm, GitConfigTemplate, - HelmImageAppForm, + HelmAppForm, HttpConfigTemplate, InlineConfigTemplate, + InlineFileForm, KubeSecretTemplate, PortMapping, - QuadletImageAppForm, - QuadletInlineAppForm, + QuadletAppForm, RolloutPolicyForm, SpecConfigTemplate, SystemdUnitFormValue, UpdatePolicyForm, getAppIdentifier, - isComposeImageAppForm, isGitConfigTemplate, - isHelmImageAppForm, isHttpConfigTemplate, isInlineConfigTemplate, isKubeSecretTemplate, - isQuadletImageAppForm, - isQuadletInlineAppForm, - isSingleContainerAppForm, } from '../../types/deviceSpec'; import { labelToString } from '../../utils/labels'; import { UpdateScheduleMode } from '../../utils/time'; @@ -370,11 +364,7 @@ const inlineAppFileSchema = (t: TFunction) => // Common test for unique file paths in inline applications const uniqueFilePathsTest = - (t: TFunction) => - ( - files: (QuadletInlineAppForm | ComposeInlineAppForm)['files'] | undefined, - testContext: Yup.TestContext, - ) => { + (t: TFunction) => (files: InlineFileForm[] | undefined, testContext: Yup.TestContext) => { if (!files || files.length === 0) { return true; } @@ -412,34 +402,33 @@ const uniqueFilePathsTest = }); }; -const composeFileName = - (t: TFunction) => (files: ComposeInlineAppForm['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; - } +const composeFileName = (t: TFunction) => (files: InlineFileForm[], testContext: Yup.TestContext) => { + const invalidFiles = files + .map((file, index) => { + if (!file.path) { return null; - }) - .filter((index): index is number => index !== 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; - }; + 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 => { @@ -454,7 +443,7 @@ const isAtRoot = (path: string): boolean => { // Validation for quadlet applications: checks for unsupported types first, then requires at least one supported type const quadletFileTypesValidation = - (t: TFunction) => (files: QuadletInlineAppForm['files'], testContext: Yup.TestContext) => { + (t: TFunction) => (files: InlineFileForm[], testContext: Yup.TestContext) => { if (!files || files.length === 0) { return true; // This is handled by the min(1) requirement } @@ -512,36 +501,35 @@ const quadletFileTypesValidation = }; // Validation for quadlet applications: quadlet files must be at root level -const quadletFilesAtRoot = - (t: TFunction) => (files: QuadletInlineAppForm['files'], testContext: Yup.TestContext) => { - if (!files || files.length === 0) { - return true; - } +const quadletFilesAtRoot = (t: TFunction) => (files: InlineFileForm[], 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; - } + const invalidFiles = files + .map((file, index) => { + if (!file.path) { return null; - }) - .filter((index): index is number => index !== 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)'), - }); - } + 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; - }; + return true; +}; const PORT_NUMBER_REGEXP = /^\d+$/; const MAX_PORT = 65535; @@ -670,7 +658,7 @@ export const validApplicationsSchema = (t: TFunction) => { .of( Yup.lazy((value: AppForm) => { // Container applications (image-based with ports and resources) - if (isSingleContainerAppForm(value)) { + if (value.appType === AppType.AppTypeContainer) { return Yup.object().shape({ specType: Yup.string() .oneOf([AppSpecType.OCI_IMAGE]) @@ -709,8 +697,8 @@ export const validApplicationsSchema = (t: TFunction) => { } // Helm applications - if (isHelmImageAppForm(value)) { - return Yup.object().shape({ + if (value.appType === AppType.AppTypeHelm) { + return Yup.object().shape({ specType: Yup.string() .oneOf([AppSpecType.OCI_IMAGE]) .required(t('Definition source must be image for this type of applications')), @@ -739,8 +727,8 @@ export const validApplicationsSchema = (t: TFunction) => { } // Image applications (Quadlet or Compose) - if (isQuadletImageAppForm(value) || isComposeImageAppForm(value)) { - return Yup.object().shape({ + if (value.specType === AppSpecType.OCI_IMAGE) { + return Yup.object().shape({ specType: Yup.string() .oneOf([AppSpecType.OCI_IMAGE]) .required(t('Definition source must be image for this type of applications')), @@ -757,8 +745,8 @@ export const validApplicationsSchema = (t: TFunction) => { } // Inline quadlet applications - if (isQuadletInlineAppForm(value)) { - return Yup.object().shape({ + if (value.appType === AppType.AppTypeQuadlet && value.specType === AppSpecType.INLINE) { + return Yup.object().shape({ specType: appSpecTypeSchema(t), appType: Yup.string().oneOf([AppType.AppTypeQuadlet]).required(t('Application type is required')), name: validApplicationAndVolumeName(t).required(t('Name is required for quadlet applications.')), @@ -772,7 +760,7 @@ export const validApplicationsSchema = (t: TFunction) => { } // Inline compose applications - return Yup.object().shape({ + return Yup.object().shape({ specType: appSpecTypeSchema(t), appType: Yup.string().oneOf([AppType.AppTypeCompose]).required(t('Application type is required')), name: validApplicationAndVolumeName(t).required(t('Name is required for compose applications.')), diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index b70ea2341..b85bb8684 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -1,16 +1,20 @@ import { - AppType, - ApplicationResourceLimits, + ApplicationProviderSpec, + ComposeApplication, ConfigProviderSpec, + ContainerApplication, DisruptionBudget, GitConfigProviderSpec, + HelmApplication, HttpConfigProviderSpec, ImageApplicationProviderSpec, ImagePullPolicy, + InlineApplicationProviderSpec, InlineConfigProviderSpec, KubernetesSecretProviderSpec, + QuadletApplication, } from '@flightctl/types'; -import { ApplicationProviderSpecFixed, FlightCtlLabel } from './extraTypes'; +import { FlightCtlLabel } from './extraTypes'; import { UpdateScheduleMode } from '../utils/time'; export const RUN_AS_DEFAULT_USER = 'flightctl'; @@ -35,77 +39,12 @@ export type GitConfigTemplate = ConfigTemplate & { path: string; }; +/** Used when adding a Compose/Quadlet app to choose image vs inline source. */ export enum AppSpecType { OCI_IMAGE = 'image', INLINE = 'inline', } -type InlineContent = { - content?: string; - path: string; - base64?: boolean; -}; - -type AppBase = { - appType: AppType; - specType: AppSpecType; - name?: string; - variables: { name: string; value: string }[]; - volumes?: ApplicationVolumeForm[]; -}; - -export type PortMapping = { - hostPort: string; - containerPort: string; -}; - -export type SingleContainerAppForm = AppBase & { - appType: AppType.AppTypeContainer; - specType: AppSpecType.OCI_IMAGE; - name: string; - image: string; - ports?: PortMapping[]; - limits?: ApplicationResourceLimits; - runAs?: string; -}; - -export type QuadletImageAppForm = AppBase & { - appType: AppType.AppTypeQuadlet; - specType: AppSpecType.OCI_IMAGE; - image: string; - runAs?: string; -}; - -export type QuadletInlineAppForm = AppBase & { - appType: AppType.AppTypeQuadlet; - specType: AppSpecType.INLINE; - name: string; // transforms the field in required - files: InlineContent[]; - runAs?: string; -}; - -export type ComposeImageAppForm = AppBase & { - appType: AppType.AppTypeCompose; - specType: AppSpecType.OCI_IMAGE; - image: string; -}; - -export type ComposeInlineAppForm = AppBase & { - appType: AppType.AppTypeCompose; - specType: AppSpecType.INLINE; - name: string; - files: InlineContent[]; -}; - -export type HelmImageAppForm = Omit & { - appType: AppType.AppTypeHelm; - specType: AppSpecType.OCI_IMAGE; - image: string; - namespace?: string; - valuesFiles: Array; - valuesYaml?: string; -}; - export const isGitConfigTemplate = (configTemplate: ConfigTemplate): configTemplate is GitConfigTemplate => configTemplate.type === ConfigType.GIT; @@ -123,50 +62,70 @@ export type RepoConfig = GitConfigProviderSpec | HttpConfigProviderSpec; export const isRepoConfig = (config: ConfigSourceProvider): config is RepoConfig => isGitProviderSpec(config) || isHttpProviderSpec(config); -export type AppForm = - | QuadletImageAppForm - | QuadletInlineAppForm - | ComposeImageAppForm - | ComposeInlineAppForm - | SingleContainerAppForm - | HelmImageAppForm; - -export const isImageAppProvider = ( - app: ApplicationProviderSpecFixed, -): app is ApplicationProviderSpecFixed & ImageApplicationProviderSpec => 'image' in app; - -// Type guards for the 5 explicit types -export const isQuadletImageAppForm = (app: AppForm): app is QuadletImageAppForm => - app.appType === AppType.AppTypeQuadlet && app.specType === AppSpecType.OCI_IMAGE; -export const isQuadletInlineAppForm = (app: AppForm): app is QuadletInlineAppForm => - app.appType === AppType.AppTypeQuadlet && app.specType === AppSpecType.INLINE; -export const isComposeImageAppForm = (app: AppForm): app is ComposeImageAppForm => - app.appType === AppType.AppTypeCompose && app.specType === AppSpecType.OCI_IMAGE; -export const isComposeInlineAppForm = (app: AppForm): app is ComposeInlineAppForm => - app.appType === AppType.AppTypeCompose && app.specType === AppSpecType.INLINE; -export const isSingleContainerAppForm = (app: AppForm): app is SingleContainerAppForm => - app.appType === AppType.AppTypeContainer; -export const isHelmImageAppForm = (app: AppForm): app is HelmImageAppForm => - app.appType === AppType.AppTypeHelm && app.specType === AppSpecType.OCI_IMAGE; +export const isImageVariantApp = ( + app: ApplicationProviderSpec, +): app is ApplicationProviderSpec & ImageApplicationProviderSpec => 'image' in app; +export const isInlineVariantApp = ( + app: ApplicationProviderSpec, +): app is ApplicationProviderSpec & InlineApplicationProviderSpec => 'inline' in app; export type ApplicationVolumeForm = { name: string; - imageRef?: string; - imagePullPolicy?: ImagePullPolicy; - mountPath?: string; + imageRef: string; + imagePullPolicy: ImagePullPolicy; + mountPath: string; }; +export type PortMapping = { + hostPort: string; + containerPort: string; +}; + +export type VariablesForm = { name: string; value: string }[]; + +export type InlineFileForm = { path: string; content?: string; base64?: boolean }; + +type InlineOrImageVariantForm = { + specType: AppSpecType; + image: string; + files: InlineFileForm[]; +}; + +export type SingleContainerAppForm = Omit & { + specType: AppSpecType.OCI_IMAGE; + ports: PortMapping[]; + cpuLimit: string; + memoryLimit: string; + variables: VariablesForm; + volumes: ApplicationVolumeForm[]; +}; + +export type HelmAppForm = Omit & { + specType: AppSpecType.OCI_IMAGE; + valuesYaml?: string; + valuesFiles: string[]; +}; + +export type QuadletAppForm = Omit & + InlineOrImageVariantForm & { + variables: VariablesForm; + volumes: ApplicationVolumeForm[]; + }; + +export type ComposeAppForm = Omit & + InlineOrImageVariantForm & { + variables: VariablesForm; + volumes: ApplicationVolumeForm[]; + }; + +export type AppForm = SingleContainerAppForm | HelmAppForm | QuadletAppForm | ComposeAppForm; + const hasTemplateVariables = (str: string) => /{{.+?}}/.test(str); -export const getAppIdentifier = (app: AppForm) => { - if (isSingleContainerAppForm(app)) { - return app.name || app.image; - } - if (isQuadletImageAppForm(app) || isComposeImageAppForm(app)) { - return app.name || app.image; - } - // Name is mandatory for inline applications - return app.name; +export const getAppIdentifier = (app: AppForm | ApplicationProviderSpec): string => { + if (app.name) return app.name; + if ('image' in app && app.image) return app.image; + return ''; }; const removeSlashes = (url: string | undefined) => (url || '').replace(/^\/+|\/+$/g, ''); @@ -214,7 +173,7 @@ export const isKubeProviderSpec = (providerSpec: ConfigProviderSpec): providerSp export type InlineConfigTemplate = ConfigTemplate & { type: ConfigType.INLINE; - files: Array; + files: Array; }; export const isInlineConfigTemplate = (configTemplate: ConfigTemplate): configTemplate is InlineConfigTemplate => diff --git a/libs/ui-components/src/types/extraTypes.ts b/libs/ui-components/src/types/extraTypes.ts index 1dd666ff7..7f13aeb1e 100644 --- a/libs/ui-components/src/types/extraTypes.ts +++ b/libs/ui-components/src/types/extraTypes.ts @@ -1,7 +1,4 @@ import { - AppType, - ApplicationEnvVars, - ApplicationVolumeProviderSpec, AuthProvider, Condition, ConditionType, @@ -9,7 +6,6 @@ import { EnrollmentRequest, FileContent, Fleet, - ImageApplicationProviderSpec, OAuth2ProviderSpec, OIDCProviderSpec, RelativePath, @@ -55,16 +51,10 @@ export type AnnotationType = DeviceAnnotation; // Add more types when they are a export const isFleet = (resource: ResourceSync | Fleet): resource is Fleet => resource.kind === 'Fleet'; -// ApplicationProviderSpec's definition for inline files adds a Record. We use the fixed types to get full Typescript checks for the field +// ApplicationProviderSpec's definition for inline files adds a Record. +// We use the fixed type to get proper Typescript checks for the field export type InlineApplicationFileFixed = FileContent & RelativePath; -// "FixedApplicationProviderSpec" will need to be manually adjusted whenever the API definition changes -export type ApplicationProviderSpecFixed = ApplicationEnvVars & - ApplicationVolumeProviderSpec & { - name?: string; - appType: AppType; - } & (ImageApplicationProviderSpec | { inline: InlineApplicationFileFixed[] }); - type CliArtifact = { os: string; arch: string; From cd38750698e7f4a5c6665b06634a3cf0cd29ccfb Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Mon, 2 Feb 2026 13:00:49 +0100 Subject: [PATCH 2/2] Properly defining integrity settings for container apps --- .../Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx | 2 -- .../Device/EditDeviceWizard/steps/ApplicationTemplates.tsx | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx index 8ad0bf80f..afc4d3c40 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx @@ -9,7 +9,6 @@ import ErrorHelperText from '../../../form/FieldHelperText'; import { isDuplicatePortMapping, isValidPortMapping, validatePortNumber } from '../../../form/validations'; import { useTranslation } from '../../../../hooks/useTranslation'; import { PortMapping, SingleContainerAppForm } from '../../../../types/deviceSpec'; -import ApplicationIntegritySettings from './ApplicationIntegritySettings'; import './ApplicationContainerForm.css'; @@ -288,7 +287,6 @@ const ApplicationContainerForm = ({ index, isReadOnly }: { index: number; isRead - ); }; 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 c3d0a6151..92e42a42d 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -145,10 +145,11 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: {specType === AppSpecType.INLINE && ( )} - {(isQuadlet || isContainer) && } )} + {(isQuadlet || isContainer) && } + {!isHelm && ( <>