From 3ea4b6af1d5e98a33e594edc8b8db7467d2b19e0 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 27 Jan 2026 15:27:22 +0100 Subject: [PATCH 1/3] EDM-2954: Allow configuring app privileges --- libs/types/models/ApplicationProviderSpec.ts | 4 ++ libs/types/models/DeviceApplicationStatus.ts | 4 ++ .../EditDeviceWizard/deviceSpecUtils.ts | 6 ++ .../steps/ApplicationContainerForm.tsx | 66 ++++++++++++++++++- .../steps/ApplicationTemplates.tsx | 3 + .../src/components/form/validations.ts | 1 + libs/ui-components/src/types/deviceSpec.ts | 4 ++ 7 files changed, 85 insertions(+), 3 deletions(-) diff --git a/libs/types/models/ApplicationProviderSpec.ts b/libs/types/models/ApplicationProviderSpec.ts index 8d6f64572..36259cf99 100644 --- a/libs/types/models/ApplicationProviderSpec.ts +++ b/libs/types/models/ApplicationProviderSpec.ts @@ -12,5 +12,9 @@ export type ApplicationProviderSpec = (ApplicationEnvVars & { */ 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)); diff --git a/libs/types/models/DeviceApplicationStatus.ts b/libs/types/models/DeviceApplicationStatus.ts index fe941c856..c2b5f5ffb 100644 --- a/libs/types/models/DeviceApplicationStatus.ts +++ b/libs/types/models/DeviceApplicationStatus.ts @@ -24,6 +24,10 @@ export type DeviceApplicationStatus = { */ embedded: boolean; appType: AppType; + /** + * The username of the system user this application is runing under. If blank, the application is run as the same user as the agent (generally root). + */ + runAs?: string; /** * Status of volumes used by this application. */ diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index bed3bdec0..3db9aed80 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -34,6 +34,7 @@ import { PortMapping, QuadletImageAppForm, QuadletInlineAppForm, + RUN_AS_DEFAULT_USER, SpecConfigTemplate, SystemdUnitFormValue, isComposeImageAppForm, @@ -280,6 +281,7 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { if (app.ports) { data.ports = app.ports.map((p) => `${p.hostPort}:${p.containerPort}`); } + data.runAs = app.runAs || RUN_AS_DEFAULT_USER; // Removed fields must not appear in the resources object const appLimits: ApplicationResourceLimits = {}; if (app.limits?.cpu) { @@ -445,6 +447,9 @@ const hasSingleContainerAppChanged = (currentApp: ApplicationProviderSpec, updat return true; } + if ((imageApp.runAs || '') !== (updatedApp.runAs || '')) { + return true; + } return !areVolumesEqual(imageApp.volumes || [], updatedApp.volumes || []); }; @@ -659,6 +664,7 @@ export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => { memory: imageApp.resources.limits.memory || '', } : undefined, + runAs: app.runAs || RUN_AS_DEFAULT_USER, }; } 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 6f2c244a0..433685aec 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx @@ -1,14 +1,31 @@ import * as React from 'react'; import { useField } from 'formik'; -import { Button, FormGroup, Grid, Label, LabelGroup, Split, SplitItem, TextInput } from '@patternfly/react-core'; +import { + Button, + Content, + FormGroup, + FormSection, + Grid, + Label, + LabelGroup, + Split, + SplitItem, + Switch, + TextInput, +} from '@patternfly/react-core'; import { ArrowRightIcon } from '@patternfly/react-icons/dist/js/icons/arrow-right-icon'; import { FormGroupWithHelperText } from '../../../common/WithHelperText'; import TextField from '../../../form/TextField'; -import ErrorHelperText from '../../../form/FieldHelperText'; +import ErrorHelperText, { DefaultHelperText } from '../../../form/FieldHelperText'; import { isDuplicatePortMapping, isValidPortMapping, validatePortNumber } from '../../../form/validations'; import { useTranslation } from '../../../../hooks/useTranslation'; -import { PortMapping, SingleContainerAppForm } from '../../../../types/deviceSpec'; +import { + PortMapping, + RUN_AS_DEFAULT_USER, + RUN_AS_ROOT_USER, + SingleContainerAppForm, +} from '../../../../types/deviceSpec'; import './ApplicationContainerForm.css'; @@ -24,6 +41,9 @@ const ApplicationContainerForm = ({ const { t } = useTranslation(); const appFieldName = `applications[${index}]`; const [{ value: ports }, , { setValue: setPorts, setTouched }] = useField(`${appFieldName}.ports`); + const [{ value: runAs }, , { setValue: setRunAs }] = useField(`${appFieldName}.runAs`); + + const isRootless = runAs !== RUN_AS_ROOT_USER; const [hostPort, setHostPort] = React.useState(''); const [containerPort, setContainerPort] = React.useState(''); const [hostPortTouched, setHostPortTouched] = React.useState(false); @@ -293,6 +313,46 @@ const ApplicationContainerForm = ({ + + + { + await setRunAs(checked ? RUN_AS_DEFAULT_USER : RUN_AS_ROOT_USER); + }} + isDisabled={isReadOnly} + /> + + {t( + 'Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.', + )} + + } + /> + + {isRootless && ( + + + + )} + ); }; 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 e281c5a17..92b1654ae 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -20,6 +20,7 @@ import { AppForm, AppSpecType, DeviceSpecConfigFormValues, + RUN_AS_DEFAULT_USER, isComposeImageAppForm, isComposeInlineAppForm, isHelmImageAppForm, @@ -78,6 +79,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: variables: [], ports: [], volumes: [], + runAs: RUN_AS_DEFAULT_USER, } as AppForm, false, ); @@ -350,6 +352,7 @@ const ApplicationTemplates = ({ isReadOnly }: { isReadOnly?: boolean }) => { variables: [], ports: [], volumes: [], + runAs: RUN_AS_DEFAULT_USER, }); }} > diff --git a/libs/ui-components/src/components/form/validations.ts b/libs/ui-components/src/components/form/validations.ts index 1e5c012bf..c6b16f713 100644 --- a/libs/ui-components/src/components/form/validations.ts +++ b/libs/ui-components/src/components/form/validations.ts @@ -704,6 +704,7 @@ export const validApplicationsSchema = (t: TFunction) => { }), volumes: singleContainerVolumesSchema(t), variables: appVariablesSchema(t), + runAs: Yup.string(), }); } diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index d66f2a0e8..9c35af81d 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -13,6 +13,9 @@ import { import { ApplicationProviderSpecFixed, FlightCtlLabel } from './extraTypes'; import { UpdateScheduleMode } from '../utils/time'; +export const RUN_AS_DEFAULT_USER = 'flightctl'; +export const RUN_AS_ROOT_USER = 'root'; + export enum ConfigType { GIT = 'git', HTTP = 'http', @@ -63,6 +66,7 @@ export type SingleContainerAppForm = AppBase & { image: string; ports?: PortMapping[]; limits?: ApplicationResourceLimits; + runAs?: string; }; export type QuadletImageAppForm = AppBase & { From bbf3f51fa561bee368eb754407aa8510ed75b873 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 27 Jan 2026 18:03:10 +0100 Subject: [PATCH 2/3] Add app privilege to Quadlet applications too --- libs/i18n/locales/en/translation.json | 8 + .../DetailsPage/Tables/ApplicationsTable.tsx | 3 + .../EditDeviceWizard/deviceSpecUtils.ts | 244 ++++++++++++------ .../steps/ApplicationImageForm.tsx | 92 +++++-- .../steps/ApplicationInlineForm.tsx | 151 +++++++---- .../steps/ApplicationTemplates.tsx | 81 +----- libs/ui-components/src/types/deviceSpec.ts | 2 + 7 files changed, 368 insertions(+), 213 deletions(-) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 03c3d90df..2ef2b27b7 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -265,6 +265,7 @@ "Device applications table": "Device applications table", "Ready": "Ready", "Restarts": "Restarts", + "Run as user": "Run as user", "Embedded": "Embedded", "Yes": "Yes", "No": "No", @@ -374,6 +375,13 @@ "Set the maximum memory usage for your container using Podman format. You can specify a number with an optional unit: \"b\" (bytes), \"k\" (kibibytes), \"m\" (mebibytes), \"g\" (gibibytes). Examples: \"512\", \"512m\", \"1g\", \"2048k\". Ensure the limit fits within your device's available memory and accounts for other applications and system processes.": "Set the maximum memory usage for your container using Podman format. You can specify a number with an optional unit: \"b\" (bytes), \"k\" (kibibytes), \"m\" (mebibytes), \"g\" (gibibytes). Examples: \"512\", \"512m\", \"1g\", \"2048k\". Ensure the limit fits within your device's available memory and accounts for other applications and system processes.", "Enter numeric value with optional unit": "Enter numeric value with optional unit", "Provide a valid memory value (e.g., \"512\", \"512m\", \"2g\", \"1024k\").": "Provide a valid memory value (e.g., \"512\", \"512m\", \"2g\", \"1024k\").", + "Access & permissions": "Access & permissions", + "System integrity protection": "System integrity protection", + "System integrity protection enabled": "System integrity protection enabled", + "System integrity protection disabled": "System integrity protection disabled", + "Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.": "Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.", + "Rootless user identity": "Rootless user identity", + "By default, workloads run as the '{{ runAsUser }}' user. To specify a custom user identity, edit the application configuration via YAML or CLI.": "By default, workloads run as the '{{ runAsUser }}' user. To specify a custom user identity, edit the application configuration via YAML or CLI.", "OCI reference URL": "OCI reference URL", "Reference to the OCI image or artifact containing the Helm chart.": "Reference to the OCI image or artifact containing the Helm chart.", "Namespace": "Namespace", diff --git a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx index 4325cc4a4..2bc43d1c3 100644 --- a/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx +++ b/libs/ui-components/src/components/DetailsPage/Tables/ApplicationsTable.tsx @@ -6,6 +6,7 @@ import { DeviceApplicationStatus } from '@flightctl/types'; import { useTranslation } from '../../../hooks/useTranslation'; import ApplicationStatus from '../../Status/ApplicationStatus'; import { getAppTypeLabel } from '../../../utils/apps'; +import { RUN_AS_DEFAULT_USER } from '../../../types/deviceSpec'; type ApplicationsTableProps = { appsStatus: DeviceApplicationStatus[]; @@ -27,6 +28,7 @@ const ApplicationsTable = ({ appsStatus }: ApplicationsTableProps) => { {t('Ready')} {t('Restarts')} {t('Type')} + {t('Run as user')} {t('Embedded')} @@ -43,6 +45,7 @@ const ApplicationsTable = ({ appsStatus }: ApplicationsTableProps) => { {app.appType ? : '-'} + {app.runAs || RUN_AS_DEFAULT_USER} {app.embedded ? t('Yes') : t('No')} ); diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index 3db9aed80..2a3db9813 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -28,6 +28,7 @@ import { ConfigSourceProvider, ConfigType, GitConfigTemplate, + HelmImageAppForm, HttpConfigTemplate, InlineConfigTemplate, KubeSecretTemplate, @@ -35,6 +36,7 @@ import { QuadletImageAppForm, QuadletInlineAppForm, RUN_AS_DEFAULT_USER, + SingleContainerAppForm, SpecConfigTemplate, SystemdUnitFormValue, isComposeImageAppForm, @@ -48,9 +50,10 @@ import { isKubeProviderSpec, isKubeSecretTemplate, isQuadletImageAppForm, + isQuadletInlineAppForm, isSingleContainerAppForm, } from '../../../types/deviceSpec'; -import { ApplicationProviderSpecFixed, InlineApplicationFileFixed } from '../../../types/extraTypes'; +import { InlineApplicationFileFixed } from '../../../types/extraTypes'; const DEFAULT_INLINE_FILE_MODE = 420; // In Octal: 0644 const DEFAULT_INLINE_FILE_USER = 'root'; @@ -281,7 +284,6 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { if (app.ports) { data.ports = app.ports.map((p) => `${p.hostPort}:${p.containerPort}`); } - data.runAs = app.runAs || RUN_AS_DEFAULT_USER; // Removed fields must not appear in the resources object const appLimits: ApplicationResourceLimits = {}; if (app.limits?.cpu) { @@ -295,6 +297,7 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { limits: appLimits, }; } + data.runAs = app.runAs || RUN_AS_DEFAULT_USER; return data; } @@ -308,23 +311,24 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { if (app.name) { data.name = app.name; } + if (isQuadletImageAppForm(app) && app.runAs) { + data.runAs = app.runAs; + } return data; } // Inline applications (Quadlet or Compose) - return { + const inlineData: ApplicationProviderSpec = { name: app.name, appType: app.appType, - inline: app.files.map( - (file): InlineApplicationFileFixed => ({ - path: file.path, - content: file.content || '', - contentEncoding: file.base64 ? EncodingType.EncodingBase64 : EncodingType.EncodingPlain, - }), - ), + inline: toInlineFiles(app.files), envVars, volumes, }; + if (isQuadletInlineAppForm(app) && app.runAs) { + inlineData.runAs = app.runAs; + } + return inlineData; }; const hasInlineApplicationChanged = ( @@ -346,6 +350,15 @@ const hasInlineApplicationChanged = ( 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 || []); }; @@ -447,7 +460,7 @@ const hasSingleContainerAppChanged = (currentApp: ApplicationProviderSpec, updat return true; } - if ((imageApp.runAs || '') !== (updatedApp.runAs || '')) { + if ((imageApp.runAs || RUN_AS_DEFAULT_USER) !== (updatedApp.runAs || RUN_AS_DEFAULT_USER)) { return true; } return !areVolumesEqual(imageApp.volumes || [], updatedApp.volumes || []); @@ -506,6 +519,13 @@ const hasApplicationChanged = (currentApp: ApplicationProviderSpec, updatedApp: return true; } + // Check runAs for Quadlet image apps + if (isQuadletImageAppForm(updatedApp)) { + if ((currentApp.runAs || RUN_AS_DEFAULT_USER) !== (updatedApp.runAs || RUN_AS_DEFAULT_USER)) { + return true; + } + } + return !areVolumesEqual(imageApp.volumes || [], updatedApp.volumes || []); } @@ -615,8 +635,16 @@ export const getApiConfig = (ct: SpecConfigTemplate): ConfigSourceProvider => { }; }; -const getAppFormVariables = (app: ApplicationProviderSpecFixed) => - Object.entries(app.envVars || {}).map(([varName, varValue]) => ({ name: varName, value: varValue })); +const getAppFormVariables = (envVars: Record | undefined) => + Object.entries(envVars || {}).map(([varName, varValue]) => ({ name: varName, value: varValue })); + +const toInlineFiles = (files: InlineApplicationFileFixed[]) => { + return files.map((file) => ({ + path: file.path || '', + content: file.content || '', + base64: file.contentEncoding === EncodingType.EncodingBase64, + })); +}; const convertVolumesToForm = (volumes?: ApplicationVolume[]) => { if (!volumes) return []; @@ -635,74 +663,144 @@ const convertVolumesToForm = (volumes?: ApplicationVolume[]) => { }); }; +const createContainerApp = ( + containerApp: (ApplicationProviderSpec & ImageApplicationProviderSpec) | undefined, +): SingleContainerAppForm => { + const ports = + containerApp?.ports?.map((portString) => { + const [hostPort, containerPort] = portString.split(':'); + return { hostPort: hostPort || '', containerPort: containerPort || '' }; + }) || []; + + 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), + ports, + limits: limits + ? { + cpu: limits.cpu || '', + memory: limits.memory || '', + } + : undefined, + runAs: containerApp?.runAs || RUN_AS_DEFAULT_USER, + }; +}; + +const createHelmApp = ( + helmApp: (ApplicationProviderSpec & ImageApplicationProviderSpec) | undefined, +): HelmImageAppForm => { + const values = helmApp?.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, + }; +}; + +const createQuadletInlineApp = ( + quadletApp: (ApplicationProviderSpec & InlineApplicationProviderSpec) | undefined, +): QuadletInlineAppForm => { + return { + appType: AppType.AppTypeQuadlet, + specType: AppSpecType.INLINE, + name: quadletApp?.name || '', + files: toInlineFiles(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 { + appType: AppType.AppTypeCompose, + specType: AppSpecType.OCI_IMAGE, + name: composeApp?.name || '', + image: composeApp?.image || '', + variables: getAppFormVariables(composeApp?.envVars), + volumes: convertVolumesToForm(composeApp?.volumes), + }; +}; + +const createComposeInlineApp = ( + composeApp: (ApplicationProviderSpec & InlineApplicationProviderSpec) | undefined, +): ComposeInlineAppForm => { + return { + appType: AppType.AppTypeCompose, + specType: AppSpecType.INLINE, + name: composeApp?.name || '', + files: toInlineFiles(composeApp?.inline || []), + variables: getAppFormVariables(composeApp?.envVars), + volumes: convertVolumesToForm(composeApp?.volumes), + }; +}; + +export const createInitialAppForm = (appType: AppType, specType: AppSpecType, name: string = ''): AppForm => { + let app: AppForm; + switch (appType) { + case AppType.AppTypeContainer: + app = createContainerApp(undefined); + break; + case AppType.AppTypeHelm: + app = createHelmApp(undefined); + break; + case AppType.AppTypeQuadlet: + app = specType === AppSpecType.OCI_IMAGE ? createQuadletImageApp(undefined) : createQuadletInlineApp(undefined); + break; + case AppType.AppTypeCompose: + app = specType === AppSpecType.OCI_IMAGE ? createComposeImageApp(undefined) : createComposeInlineApp(undefined); + break; + } + 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'); } - // Single container application - if (app.appType === AppType.AppTypeContainer && isImageAppProvider(app)) { - const imageApp = app as ImageApplicationProviderSpec; - const ports = - imageApp.ports?.map((portString) => { - const [hostPort, containerPort] = portString.split(':'); - return { hostPort: hostPort || '', containerPort: containerPort || '' }; - }) || []; - - return { - appType: AppType.AppTypeContainer, - specType: AppSpecType.OCI_IMAGE, - name: app.name || '', - image: app.image, - variables: getAppFormVariables(app), - volumes: convertVolumesToForm(app.volumes), - ports, - limits: imageApp.resources?.limits - ? { - cpu: imageApp.resources.limits.cpu || '', - memory: imageApp.resources.limits.memory || '', - } - : undefined, - runAs: app.runAs || RUN_AS_DEFAULT_USER, - }; - } - - // Helm application - if (app.appType === AppType.AppTypeHelm && isImageAppProvider(app)) { - return { - appType: AppType.AppTypeHelm, - specType: AppSpecType.OCI_IMAGE, - name: app.name || '', - image: app.image, - namespace: app.namespace, - valuesYaml: app.values && Object.keys(app.values).length > 0 ? yaml.dump(app.values) : undefined, - valuesFiles: app.valuesFiles || [''], - }; - } - // Compose or Quadlet image application - if (isImageAppProvider(app)) { - return { - appType: app.appType, - specType: AppSpecType.OCI_IMAGE, - name: app.name || '', - image: app.image, - variables: getAppFormVariables(app), - volumes: convertVolumesToForm(app.volumes), - } as QuadletImageAppForm | ComposeImageAppForm; + 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); } - - // Compose or Quadlet inline application - const inlineApp = app as InlineApplicationProviderSpec; - return { - appType: app.appType, - specType: AppSpecType.INLINE, - name: app.name || '', - files: inlineApp.inline, - variables: getAppFormVariables(app), - volumes: convertVolumesToForm(app.volumes), - } as QuadletInlineAppForm | ComposeInlineAppForm; }); }; 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 2489bde12..310e25df5 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx @@ -1,11 +1,20 @@ import * as React from 'react'; +import { useField } from 'formik'; +import { Content, FormGroup, FormSection, Grid, Switch } from '@patternfly/react-core'; import { FormGroupWithHelperText } from '../../../common/WithHelperText'; import TextField from '../../../form/TextField'; import LearnMoreLink from '../../../common/LearnMoreLink'; +import { DefaultHelperText } from '../../../form/FieldHelperText'; import { useTranslation } from '../../../../hooks/useTranslation'; import { useAppLinks } from '../../../../hooks/useAppLinks'; -import { ComposeImageAppForm, QuadletImageAppForm } from '../../../../types/deviceSpec'; +import { + ComposeImageAppForm, + QuadletImageAppForm, + RUN_AS_DEFAULT_USER, + RUN_AS_ROOT_USER, + isQuadletImageAppForm, +} from '../../../../types/deviceSpec'; const ApplicationImageForm = ({ app, @@ -18,24 +27,73 @@ const ApplicationImageForm = ({ }) => { const { t } = useTranslation(); const createAppLink = useAppLinks('createApp'); + const appFieldName = `applications[${index}]`; + const isQuadlet = isQuadletImageAppForm(app); + const [{ value: runAs }, , { setValue: setRunAs }] = useField(`${appFieldName}.runAs`); + const isRootless = runAs !== RUN_AS_ROOT_USER; return ( - - {t('The application image. Learn how to create one')} - - } - isRequired - > - - + + + {t('The application image. Learn how to create one')}{' '} + + + } + isRequired + > + + + {isQuadlet && ( + + + { + await setRunAs(checked ? RUN_AS_DEFAULT_USER : RUN_AS_ROOT_USER); + }} + isDisabled={isReadOnly} + /> + + {t( + 'Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.', + )} + + } + /> + + {isRootless && ( + + + + )} + + )} + ); }; 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 a6a573eed..f974c4328 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { FieldArray } from 'formik'; -import { Button, FormGroup, Grid, Split, SplitItem } from '@patternfly/react-core'; +import { FieldArray, useField } from 'formik'; +import { Button, Content, FormGroup, FormSection, Grid, Split, SplitItem, Switch } 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'; @@ -9,7 +9,14 @@ 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 { DefaultHelperText } from '../../../form/FieldHelperText'; +import { + ComposeInlineAppForm, + QuadletInlineAppForm, + RUN_AS_DEFAULT_USER, + RUN_AS_ROOT_USER, + isQuadletInlineAppForm, +} from '../../../../types/deviceSpec'; const MAX_INLINE_FILE_SIZE_BYTES = 1024 * 1024; @@ -64,63 +71,111 @@ const ApplicationInlineForm = ({ isReadOnly?: boolean; }) => { const { t } = useTranslation(); + const appFieldName = `applications[${index}]`; + const isQuadlet = isQuadletInlineAppForm(app); + const [{ value: runAs }, , { setValue: setRunAs }] = useField(`${appFieldName}.runAs`); + const isRootless = runAs !== RUN_AS_ROOT_USER; if (isReadOnly && !app.files?.length) { return null; } return ( - - {({ push, remove }) => ( - <> - {app.files?.map((file, fileIndex) => { - const fieldName = `applications[${index}].files[${fileIndex}]`; - return ( - - - - - {!isReadOnly && app.files.length > 1 && ( - - + {!isReadOnly && ( + + + + )} + + )} + + {isQuadlet && ( + + + { + await setRunAs(checked ? RUN_AS_DEFAULT_USER : RUN_AS_ROOT_USER); + }} + isDisabled={isReadOnly} + /> + + {t( + 'Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.', + )} + + } + /> + + {isRootless && ( + + )} - + )} - + ); }; 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 92b1654ae..cf984990d 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -20,7 +20,6 @@ import { AppForm, AppSpecType, DeviceSpecConfigFormValues, - RUN_AS_DEFAULT_USER, isComposeImageAppForm, isComposeInlineAppForm, isHelmImageAppForm, @@ -28,6 +27,7 @@ import { isQuadletInlineAppForm, isSingleContainerAppForm, } from '../../../../types/deviceSpec'; +import { createInitialAppForm } from '../deviceSpecUtils'; import { useTranslation } from '../../../../hooks/useTranslation'; import TextField from '../../../form/TextField'; import FormSelect from '../../../form/FormSelect'; @@ -65,70 +65,10 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: const appTypesOptions = appTypeOptions(t); React.useEffect(() => { - if (!shouldResetApp) { - return; - } - // When switching appType to Container, initialize Container-specific fields - if (appType === AppType.AppTypeContainer) { - setValue( - { - appType: AppType.AppTypeContainer, - specType: AppSpecType.OCI_IMAGE, - name: appName || '', - image: '', - variables: [], - ports: [], - volumes: [], - runAs: RUN_AS_DEFAULT_USER, - } as AppForm, - false, - ); - return; - } - - if (appType === AppType.AppTypeHelm) { - setValue( - { - appType: AppType.AppTypeHelm, - specType: AppSpecType.OCI_IMAGE, - name: appName || '', - image: '', - namespace: undefined, - valuesYaml: '', - valuesFiles: [''], // We want to show a "values files" field by default - } as AppForm, - false, - ); - return; - } - - // When switching specType, the app becomes "incomplete" and we must add the required fields for the new type - if (specType === AppSpecType.INLINE) { - // Switching to inline - need files - setValue( - { - specType: AppSpecType.INLINE, - appType, - name: appName || '', - files: [{ path: '', content: '' }], - variables: [], - volumes: [], - } as AppForm, - false, - ); - } else if (specType === AppSpecType.OCI_IMAGE) { - // Switching to image - need image field - setValue( - { - specType: AppSpecType.OCI_IMAGE, - appType, - name: appName || '', - image: '', - variables: [], - volumes: [], - } as AppForm, - false, - ); + // When switching types we must ensure all mandatory fields are initialized for the new type + if (shouldResetApp) { + const app = createInitialAppForm(appType, specType, appName || ''); + setValue(app, false); } }, [shouldResetApp, specType, appType, appName, setValue]); @@ -344,16 +284,7 @@ const ApplicationTemplates = ({ isReadOnly }: { isReadOnly?: boolean }) => { icon={} iconPosition="start" onClick={() => { - push({ - appType: AppType.AppTypeContainer, - specType: AppSpecType.OCI_IMAGE, - name: '', - image: '', - variables: [], - ports: [], - volumes: [], - runAs: RUN_AS_DEFAULT_USER, - }); + push(createInitialAppForm(AppType.AppTypeContainer, AppSpecType.OCI_IMAGE)); }} > {t('Add application')} diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index 9c35af81d..b70ea2341 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -73,6 +73,7 @@ export type QuadletImageAppForm = AppBase & { appType: AppType.AppTypeQuadlet; specType: AppSpecType.OCI_IMAGE; image: string; + runAs?: string; }; export type QuadletInlineAppForm = AppBase & { @@ -80,6 +81,7 @@ export type QuadletInlineAppForm = AppBase & { specType: AppSpecType.INLINE; name: string; // transforms the field in required files: InlineContent[]; + runAs?: string; }; export type ComposeImageAppForm = AppBase & { From 18d97a557ab60b8c6fc0bfd4fc5726859a6945e1 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Wed, 28 Jan 2026 16:44:14 +0100 Subject: [PATCH 3/3] Reusable component for Application Integrity --- libs/i18n/locales/en/translation.json | 14 ++-- .../EditDeviceWizard/deviceSpecUtils.ts | 16 +++-- .../steps/ApplicationContainerForm.tsx | 67 ++----------------- .../steps/ApplicationImageForm.tsx | 58 +--------------- .../steps/ApplicationInlineForm.tsx | 59 +--------------- .../steps/ApplicationIntegritySettings.tsx | 65 ++++++++++++++++++ .../steps/ApplicationTemplates.tsx | 3 + 7 files changed, 97 insertions(+), 185 deletions(-) create mode 100644 libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationIntegritySettings.tsx diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 2ef2b27b7..a77d83e70 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -375,13 +375,6 @@ "Set the maximum memory usage for your container using Podman format. You can specify a number with an optional unit: \"b\" (bytes), \"k\" (kibibytes), \"m\" (mebibytes), \"g\" (gibibytes). Examples: \"512\", \"512m\", \"1g\", \"2048k\". Ensure the limit fits within your device's available memory and accounts for other applications and system processes.": "Set the maximum memory usage for your container using Podman format. You can specify a number with an optional unit: \"b\" (bytes), \"k\" (kibibytes), \"m\" (mebibytes), \"g\" (gibibytes). Examples: \"512\", \"512m\", \"1g\", \"2048k\". Ensure the limit fits within your device's available memory and accounts for other applications and system processes.", "Enter numeric value with optional unit": "Enter numeric value with optional unit", "Provide a valid memory value (e.g., \"512\", \"512m\", \"2g\", \"1024k\").": "Provide a valid memory value (e.g., \"512\", \"512m\", \"2g\", \"1024k\").", - "Access & permissions": "Access & permissions", - "System integrity protection": "System integrity protection", - "System integrity protection enabled": "System integrity protection enabled", - "System integrity protection disabled": "System integrity protection disabled", - "Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.": "Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.", - "Rootless user identity": "Rootless user identity", - "By default, workloads run as the '{{ runAsUser }}' user. To specify a custom user identity, edit the application configuration via YAML or CLI.": "By default, workloads run as the '{{ runAsUser }}' user. To specify a custom user identity, edit the application configuration via YAML or CLI.", "OCI reference URL": "OCI reference URL", "Reference to the OCI image or artifact containing the Helm chart.": "Reference to the OCI image or artifact containing the Helm chart.", "Namespace": "Namespace", @@ -408,6 +401,13 @@ "Content is base64 encoded": "Content is base64 encoded", "Delete file": "Delete file", "Add file": "Add file", + "Access & permissions": "Access & permissions", + "System integrity protection": "System integrity protection", + "System integrity protection enabled": "System integrity protection enabled", + "System integrity protection disabled": "System integrity protection disabled", + "Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.": "Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.", + "Rootless user identity": "Rootless user identity", + "By default, workloads run as the '{{ runAsUser }}' user. To specify a custom user identity, edit the application configuration via YAML or CLI.": "By default, workloads run as the '{{ runAsUser }}' user. To specify a custom user identity, edit the application configuration via YAML or CLI.", "Application {{ appNum }}": "Application {{ appNum }}", "Application type": "Application type", "Select an application type": "Select an application type", diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index 2a3db9813..490c4f20a 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -321,7 +321,7 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { const inlineData: ApplicationProviderSpec = { name: app.name, appType: app.appType, - inline: toInlineFiles(app.files), + inline: toAPIFiles(app.files), envVars, volumes, }; @@ -638,7 +638,7 @@ export const getApiConfig = (ct: SpecConfigTemplate): ConfigSourceProvider => { const getAppFormVariables = (envVars: Record | undefined) => Object.entries(envVars || {}).map(([varName, varValue]) => ({ name: varName, value: varValue })); -const toInlineFiles = (files: InlineApplicationFileFixed[]) => { +const toFormFiles = (files: InlineApplicationFileFixed[]) => { return files.map((file) => ({ path: file.path || '', content: file.content || '', @@ -646,6 +646,14 @@ const toInlineFiles = (files: InlineApplicationFileFixed[]) => { })); }; +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) => { @@ -727,7 +735,7 @@ const createQuadletInlineApp = ( appType: AppType.AppTypeQuadlet, specType: AppSpecType.INLINE, name: quadletApp?.name || '', - files: toInlineFiles(quadletApp?.inline || []), + files: toFormFiles(quadletApp?.inline || []), variables: getAppFormVariables(quadletApp?.envVars), volumes: convertVolumesToForm(quadletApp?.volumes), runAs: quadletApp?.runAs || RUN_AS_DEFAULT_USER, @@ -754,7 +762,7 @@ const createComposeInlineApp = ( appType: AppType.AppTypeCompose, specType: AppSpecType.INLINE, name: composeApp?.name || '', - files: toInlineFiles(composeApp?.inline || []), + files: toFormFiles(composeApp?.inline || []), variables: getAppFormVariables(composeApp?.envVars), volumes: convertVolumesToForm(composeApp?.volumes), }; 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 433685aec..ce2296d75 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx @@ -1,31 +1,15 @@ import * as React from 'react'; import { useField } from 'formik'; -import { - Button, - Content, - FormGroup, - FormSection, - Grid, - Label, - LabelGroup, - Split, - SplitItem, - Switch, - TextInput, -} from '@patternfly/react-core'; +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'; import { FormGroupWithHelperText } from '../../../common/WithHelperText'; import TextField from '../../../form/TextField'; -import ErrorHelperText, { DefaultHelperText } from '../../../form/FieldHelperText'; +import ErrorHelperText from '../../../form/FieldHelperText'; import { isDuplicatePortMapping, isValidPortMapping, validatePortNumber } from '../../../form/validations'; import { useTranslation } from '../../../../hooks/useTranslation'; -import { - PortMapping, - RUN_AS_DEFAULT_USER, - RUN_AS_ROOT_USER, - SingleContainerAppForm, -} from '../../../../types/deviceSpec'; +import { PortMapping, SingleContainerAppForm } from '../../../../types/deviceSpec'; +import ApplicationIntegritySettings from './ApplicationIntegritySettings'; import './ApplicationContainerForm.css'; @@ -41,9 +25,7 @@ const ApplicationContainerForm = ({ const { t } = useTranslation(); const appFieldName = `applications[${index}]`; const [{ value: ports }, , { setValue: setPorts, setTouched }] = useField(`${appFieldName}.ports`); - const [{ value: runAs }, , { setValue: setRunAs }] = useField(`${appFieldName}.runAs`); - const isRootless = runAs !== RUN_AS_ROOT_USER; const [hostPort, setHostPort] = React.useState(''); const [containerPort, setContainerPort] = React.useState(''); const [hostPortTouched, setHostPortTouched] = React.useState(false); @@ -313,46 +295,7 @@ const ApplicationContainerForm = ({ - - - { - await setRunAs(checked ? RUN_AS_DEFAULT_USER : RUN_AS_ROOT_USER); - }} - isDisabled={isReadOnly} - /> - - {t( - 'Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.', - )} - - } - /> - - {isRootless && ( - - - - )} - + ); }; 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 310e25df5..53409ccd5 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationImageForm.tsx @@ -1,20 +1,12 @@ import * as React from 'react'; -import { useField } from 'formik'; -import { Content, FormGroup, FormSection, Grid, Switch } from '@patternfly/react-core'; +import { Grid } from '@patternfly/react-core'; import { FormGroupWithHelperText } from '../../../common/WithHelperText'; import TextField from '../../../form/TextField'; import LearnMoreLink from '../../../common/LearnMoreLink'; -import { DefaultHelperText } from '../../../form/FieldHelperText'; import { useTranslation } from '../../../../hooks/useTranslation'; import { useAppLinks } from '../../../../hooks/useAppLinks'; -import { - ComposeImageAppForm, - QuadletImageAppForm, - RUN_AS_DEFAULT_USER, - RUN_AS_ROOT_USER, - isQuadletImageAppForm, -} from '../../../../types/deviceSpec'; +import { ComposeImageAppForm, QuadletImageAppForm } from '../../../../types/deviceSpec'; const ApplicationImageForm = ({ app, @@ -27,10 +19,6 @@ const ApplicationImageForm = ({ }) => { const { t } = useTranslation(); const createAppLink = useAppLinks('createApp'); - const appFieldName = `applications[${index}]`; - const isQuadlet = isQuadletImageAppForm(app); - const [{ value: runAs }, , { setValue: setRunAs }] = useField(`${appFieldName}.runAs`); - const isRootless = runAs !== RUN_AS_ROOT_USER; return ( @@ -51,48 +39,6 @@ const ApplicationImageForm = ({ isDisabled={isReadOnly} /> - {isQuadlet && ( - - - { - await setRunAs(checked ? RUN_AS_DEFAULT_USER : RUN_AS_ROOT_USER); - }} - isDisabled={isReadOnly} - /> - - {t( - 'Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.', - )} - - } - /> - - {isRootless && ( - - - - )} - - )} ); }; 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 f974c4328..7ea6b3a5a 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationInlineForm.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { FieldArray, useField } from 'formik'; -import { Button, Content, FormGroup, FormSection, Grid, Split, SplitItem, Switch } from '@patternfly/react-core'; +import { FieldArray } from 'formik'; +import { Button, FormGroup, 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'; @@ -9,14 +9,7 @@ import CheckboxField from '../../../form/CheckboxField'; import UploadField from '../../../form/UploadField'; import TextField from '../../../form/TextField'; import ExpandableFormSection from '../../../form/ExpandableFormSection'; -import { DefaultHelperText } from '../../../form/FieldHelperText'; -import { - ComposeInlineAppForm, - QuadletInlineAppForm, - RUN_AS_DEFAULT_USER, - RUN_AS_ROOT_USER, - isQuadletInlineAppForm, -} from '../../../../types/deviceSpec'; +import { ComposeInlineAppForm, QuadletInlineAppForm } from '../../../../types/deviceSpec'; const MAX_INLINE_FILE_SIZE_BYTES = 1024 * 1024; @@ -71,10 +64,6 @@ const ApplicationInlineForm = ({ isReadOnly?: boolean; }) => { const { t } = useTranslation(); - const appFieldName = `applications[${index}]`; - const isQuadlet = isQuadletInlineAppForm(app); - const [{ value: runAs }, , { setValue: setRunAs }] = useField(`${appFieldName}.runAs`); - const isRootless = runAs !== RUN_AS_ROOT_USER; if (isReadOnly && !app.files?.length) { return null; @@ -133,48 +122,6 @@ const ApplicationInlineForm = ({ )} - {isQuadlet && ( - - - { - await setRunAs(checked ? RUN_AS_DEFAULT_USER : RUN_AS_ROOT_USER); - }} - isDisabled={isReadOnly} - /> - - {t( - 'Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.', - )} - - } - /> - - {isRootless && ( - - - - )} - - )} ); }; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationIntegritySettings.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationIntegritySettings.tsx new file mode 100644 index 000000000..2b61e1c6c --- /dev/null +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationIntegritySettings.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { useField } from 'formik'; +import { Content, FormGroup, FormSection, Switch } from '@patternfly/react-core'; + +import { useTranslation } from '../../../../hooks/useTranslation'; +import TextField from '../../../form/TextField'; +import { DefaultHelperText } from '../../../form/FieldHelperText'; +import { RUN_AS_DEFAULT_USER, RUN_AS_ROOT_USER } from '../../../../types/deviceSpec'; + +type ApplicationIntegritySettingsProps = { + index: number; + isReadOnly?: boolean; +}; + +const ApplicationIntegritySettings = ({ index, isReadOnly }: ApplicationIntegritySettingsProps) => { + const { t } = useTranslation(); + const appFieldName = `applications[${index}]`; + const [{ value: runAs }, , { setValue: setRunAs }] = useField(`${appFieldName}.runAs`); + const isRootless = runAs !== RUN_AS_ROOT_USER; + + return ( + + + { + await setRunAs(checked ? RUN_AS_DEFAULT_USER : RUN_AS_ROOT_USER); + }} + isDisabled={isReadOnly} + /> + + {t( + 'Prevents this workload from modifying critical host operating system files. We recommend keeping this enabled to maintain system integrity.', + )} + + } + /> + + {isRootless && ( + + + + )} + + ); +}; + +export default ApplicationIntegritySettings; 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 cf984990d..5ebfa7cff 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -41,6 +41,7 @@ import ApplicationInlineForm from './ApplicationInlineForm'; import ApplicationContainerForm from './ApplicationContainerForm'; import ApplicationHelmForm from './ApplicationHelmForm'; import ApplicationVolumeForm from './ApplicationVolumeForm'; +import ApplicationIntegritySettings from './ApplicationIntegritySettings'; import './ApplicationsForm.css'; @@ -52,6 +53,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: const isContainer = isSingleContainerAppForm(app); const isHelm = isHelmImageAppForm(app); + const isQuadlet = isQuadletImageAppForm(app) || isQuadletInlineAppForm(app); const isImageIncomplete = !isContainer && specType === AppSpecType.OCI_IMAGE && !('image' in app); const isInlineIncomplete = !isContainer && specType === AppSpecType.INLINE && !('files' in app); const isContainerIncomplete = isContainer && (!('ports' in app) || !('volumes' in app)); @@ -156,6 +158,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: {(isQuadletInlineAppForm(app) || isComposeInlineAppForm(app)) && ( )} + {isQuadlet && } )}