1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

fix(app templates): load app template for deployment [BE-11382] (#141)

This commit is contained in:
Ali 2024-11-25 17:41:09 +13:00 committed by GitHub
parent 20e3d3a15b
commit c0c7144539
23 changed files with 453 additions and 60 deletions

View file

@ -0,0 +1,186 @@
import { DefaultBodyType, HttpResponse } from 'msw';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { CreateForm } from './CreateForm';
// browser address
// /edge/stacks/new?templateId=54&templateType=app
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { templateId: 54, templateType: 'app' },
})),
}));
vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: () => <div />,
}));
// app templates request
// GET /api/templates
const templatesResponseBody = {
version: '3',
templates: [
{
id: 54,
type: 3,
title: 'TOSIBOX Lock for Container',
description:
'Lock for Container brings secure connectivity inside your industrial IoT devices',
administrator_only: false,
image: '',
repository: {
url: 'https://github.com/portainer/templates',
stackfile: 'stacks/tosibox/docker-compose.yml',
},
stackFile: '',
logo: 'https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/tosibox.png',
env: [
{
name: 'LICENSE_KEY',
label: 'License key',
},
],
platform: 'linux',
categories: ['edge'],
},
],
};
// app template content request
// GET /api/templates/54/file
const templateContentResponseBody = {
FileContent:
// eslint-disable-next-line no-template-curly-in-string
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
};
// edge groups
const edgeGroups = [
{
Id: 1,
Name: 'docker',
Dynamic: false,
TagIds: [],
Endpoints: [12],
PartialMatch: false,
HasEdgeStack: false,
HasEdgeJob: false,
EndpointTypes: [4],
TrustedEndpoints: [12],
},
{
Id: 2,
Name: 'kubernetes',
Dynamic: false,
TagIds: [],
Endpoints: [11],
PartialMatch: false,
HasEdgeStack: false,
HasEdgeJob: false,
EndpointTypes: [7],
TrustedEndpoints: [11],
},
];
// expected form values
const expectedPayload = {
deploymentType: 0,
edgeGroups: [1],
name: 'my-stack',
envVars: [{ name: 'LICENSE_KEY', value: 'license-123' }],
prePullImage: false,
registries: [],
retryDeploy: false,
staggerConfig: {
StaggerOption: 1,
StaggerParallelOption: 1,
DeviceNumber: 1,
DeviceNumberStartFrom: 0,
DeviceNumberIncrementBy: 2,
Timeout: '',
UpdateDelay: '',
UpdateFailureAction: 1,
},
useManifestNamespaces: false,
stackFileContent:
// eslint-disable-next-line no-template-curly-in-string
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
};
function renderCreateForm() {
server.use(
http.get('/api/templates', () => HttpResponse.json(templatesResponseBody))
);
server.use(
http.post('/api/templates/54/file', () =>
HttpResponse.json(templateContentResponseBody)
)
);
server.use(http.get('/api/edge_stacks', () => HttpResponse.json([])));
server.use(http.get('/api/edge_groups', () => HttpResponse.json(edgeGroups)));
server.use(http.get('/api/registries', () => HttpResponse.json([])));
server.use(http.get('/api/custom_templates', () => HttpResponse.json([])));
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(CreateForm), user)
);
return render(<Wrapped />);
}
test('The web editor should be visible for app templates', async () => {
const { getByRole, getByLabelText } = renderCreateForm();
// Wait for the form to be rendered
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// the web editor should be visible
expect(getByLabelText('Web editor')).toBeVisible();
});
test('The form should submit the correct request body', async () => {
let requestBody: DefaultBodyType;
server.use(
http.post('/api/edge_stacks/create/string', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({});
})
);
const { getByRole, getByLabelText } = renderCreateForm();
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// fill in the name and select the docker edge group
const user = userEvent.setup();
await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack');
await user.type(
getByRole('textbox', { name: 'License key *' }),
'license-123'
);
const selectElement = getByLabelText('Edge groups');
await selectEvent.select(selectElement, 'docker');
// submit the form
await user.click(getByRole('button', { name: /Deploy the stack/i }));
// verify the request body
await waitFor(() => {
expect(requestBody).toEqual(expectedPayload);
});
});

View file

@ -18,12 +18,15 @@ import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFie
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
import { file } from '@@/form-components/yup-file-validation';
import { DeploymentType } from '../types';
import { staggerConfigValidation } from '../components/StaggerFieldset';
import { createHasEnvironmentTypeFunction } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
import { FormValues, Method } from './types';
import { templateFieldsetValidation } from './TemplateFieldset/validation';
@ -39,6 +42,8 @@ export function useValidation({
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const nameValidation = useNameValidation();
const edgeGroupsQuery = useEdgeGroups();
const edgeGroups = edgeGroupsQuery.data;
return useMemo(
() =>
@ -53,7 +58,47 @@ export function useValidation({
.min(1, 'At least one Edge group is required'),
deploymentType: mixed<DeploymentType>()
.oneOf([DeploymentType.Compose, DeploymentType.Kubernetes])
.required(),
.required()
.test(
'kubernetes-deployment-type-validation',
'Kubernetes deployment type is not compatible with the selected edge group(s), which contain Docker environments',
(value) => {
if (value !== DeploymentType.Kubernetes) {
return true;
}
const hasType = createHasEnvironmentTypeFunction(
values.groupIds,
edgeGroups
);
const hasDockerEndpoint = hasType(
EnvironmentType.EdgeAgentOnDocker
);
return !hasDockerEndpoint;
}
)
.test(
'compose-deployment-type-validation',
'Compose deployment type is not compatible with the selected edge group(s), which contain Kubernetes environments',
(value) => {
if (value !== DeploymentType.Compose) {
return true;
}
const hasType = createHasEnvironmentTypeFunction(
values.groupIds,
edgeGroups
);
const hasKubeEndpoint = hasType(
EnvironmentType.EdgeAgentOnKubernetes
);
return !hasKubeEndpoint;
}
),
envVars: envVarValidation(),
privateRegistryId: number().default(0),
prePullImage: boolean().default(false),
@ -92,6 +137,12 @@ export function useValidation({
useManifestNamespaces: boolean().default(false),
})
),
[appTemplate?.Env, customTemplate, gitCredentialsQuery.data, nameValidation]
[
appTemplate?.Env,
customTemplate,
edgeGroups,
gitCredentialsQuery.data,
nameValidation,
]
);
}

View file

@ -6,10 +6,10 @@ export function CreateView() {
return (
<>
<PageHeader
title="Create Edge stack"
title="Create Edge Stack"
breadcrumbs={[
{ label: 'Edge Stacks', link: 'edge.stacks' },
'Create Edge stack',
'Create Edge Stack',
]}
reload
/>

View file

@ -16,9 +16,10 @@ import {
import { FileUploadForm } from '@@/form-components/FileUpload';
import { TemplateFieldset } from './TemplateFieldset/TemplateFieldset';
import { useRenderTemplate } from './useRenderTemplate';
import { useRenderCustomTemplate } from './useRenderCustomTemplate';
import { DockerFormValues } from './types';
import { DockerContentField } from './DockerContentField';
import { useRenderAppTemplate } from './useRenderAppTemplate';
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
@ -38,7 +39,14 @@ export function DockerComposeForm({
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
const { method } = values;
const template = useRenderTemplate(values.templateValues, setValues);
const { customTemplate, isInitialLoading: isCustomTemplateLoading } =
useRenderCustomTemplate(values.templateValues, setValues);
const { appTemplate, isInitialLoading: isAppTemplateLoading } =
useRenderAppTemplate(values.templateValues, setValues);
const isTemplate =
method === edgeStackTemplate.value && (customTemplate || appTemplate);
const isTemplateLoading = isCustomTemplateLoading || isAppTemplateLoading;
return (
<>
@ -73,15 +81,17 @@ export function DockerComposeForm({
})
}
errors={errors?.templateValues}
isLoadingValues={isTemplateLoading}
/>
)}
{(method === editor.value ||
(method === edgeStackTemplate.value && template)) && (
{(method === editor.value || isTemplate) && !isTemplateLoading && (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
readonly={method === edgeStackTemplate.value && !!template?.GitConfig}
readonly={
method === edgeStackTemplate.value && !!customTemplate?.GitConfig
}
error={errors?.fileContent}
/>
)}

View file

@ -3,7 +3,6 @@ import { Form, useFormikContext } from 'formik';
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { TextTip } from '@@/Tip/TextTip';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { FormActions } from '@@/form-components/FormActions';
@ -11,7 +10,7 @@ import { EdgeGroupsSelector } from '../components/EdgeGroupsSelector';
import { EdgeStackDeploymentTypeSelector } from '../components/EdgeStackDeploymentTypeSelector';
import { StaggerFieldset } from '../components/StaggerFieldset';
import { PrivateRegistryFieldsetWrapper } from '../ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper';
import { useValidateEnvironmentTypes } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
import { useEdgeGroupHasType } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
import { DeploymentType } from '../types';
import { DockerComposeForm } from './DockerComposeForm';
@ -38,13 +37,20 @@ export function InnerForm({
}) {
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
useFormikContext<FormValues>();
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
const { hasType } = useEdgeGroupHasType(values.groupIds);
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
const hasMultipleTypes = hasKubeEndpoint && hasDockerEndpoint;
const multipleTypesError = hasMultipleTypes
? `There are no available deployment types when there is more than one
type of environment in your edge group selection (e.g. Kubernetes and
Docker environments). Please select edge groups that have environments
of the same type.`
: undefined;
return (
<Form className="form-horizontal">
<Form className="form-horizontal" role="form">
<NameField
onChange={(value) => setFieldValue('name', value)}
value={values.name}
@ -54,23 +60,15 @@ export function InnerForm({
<EdgeGroupsSelector
value={values.groupIds}
onChange={(value) => setFieldValue('groupIds', value)}
error={errors.groupIds}
error={errors.groupIds || multipleTypesError}
/>
{hasKubeEndpoint && hasDockerEndpoint && (
<TextTip>
There are no available deployment types when there is more than one
type of environment in your edge group selection (e.g. Kubernetes and
Docker environments). Please select edge groups that have environments
of the same type.
</TextTip>
)}
<EdgeStackDeploymentTypeSelector
value={values.deploymentType}
hasDockerEndpoint={hasDockerEndpoint}
hasKubeEndpoint={hasKubeEndpoint}
onChange={(value) => setFieldValue('deploymentType', value)}
error={errors.deploymentType}
/>
{values.deploymentType === DeploymentType.Compose && (

View file

@ -16,10 +16,12 @@ export function TemplateFieldset({
values,
setValues,
errors,
isLoadingValues,
}: {
errors?: FormikErrors<Values>;
values: Values;
setValues: (values: SetStateAction<Values>) => void;
isLoadingValues?: boolean;
}) {
return (
<>
@ -27,8 +29,9 @@ export function TemplateFieldset({
error={errors?.templateId}
value={values}
onChange={handleChangeTemplate}
isLoadingValues={isLoadingValues}
/>
{values.templateId && (
{values.templateId && !isLoadingValues && (
<>
{values.type === 'custom' && (
<CustomTemplateFieldset

View file

@ -9,6 +9,7 @@ import { CustomTemplate } from '@/react/portainer/templates/custom-templates/typ
import { FormControl } from '@@/form-components/FormControl';
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
import { InlineLoader } from '@@/InlineLoader';
import { SelectedTemplateValue } from './types';
@ -16,6 +17,7 @@ export function TemplateSelector({
value,
onChange,
error,
isLoadingValues,
}: {
value: SelectedTemplateValue;
onChange: (
@ -23,6 +25,7 @@ export function TemplateSelector({
type: 'app' | 'custom' | undefined
) => void;
error?: string;
isLoadingValues?: boolean;
}) {
const { options, getTemplate, selectedValue } = useOptions(value);
@ -48,6 +51,9 @@ export function TemplateSelector({
}}
data-cy="edge-stacks-create-template-selector"
/>
{isLoadingValues && (
<InlineLoader>Loading template values...</InlineLoader>
)}
</FormControl>
);
}

View file

@ -102,12 +102,18 @@ export function useCreate({
}
function getBasePayload(values: FormValues): BasePayload {
const templateEnvVarsAsPairs = Object.entries(
values.templateValues.envVars
).map(([name, value]) => ({
name,
value,
}));
return {
userId: user.Id,
deploymentType: values.deploymentType,
edgeGroups: values.groupIds,
name: values.name,
envVars: values.envVars,
envVars: [...values.envVars, ...templateEnvVarsAsPairs],
registries: values.privateRegistryId ? [values.privateRegistryId] : [],
prePullImage: values.prePullImage,
retryDeploy: values.retryDeploy,

View file

@ -0,0 +1,96 @@
import { SetStateAction, useEffect, useState } from 'react';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { useAppTemplateFile } from '@/react/portainer/templates/app-templates/queries/useAppTemplateFile';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { DeploymentType } from '../types';
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
import { DockerFormValues, FormValues } from './types';
/**
* useRenderAppTemplate fetches the app template (file and data) and returns it
* as a TemplateViewModel.
*
* It also renders the template file and updates the form values.
*/
export function useRenderAppTemplate(
templateValues: DockerFormValues['templateValues'],
setValues: (values: SetStateAction<DockerFormValues>) => void
) {
const templateQuery = useAppTemplate(templateValues.templateId, {
enabled: templateValues.type === 'app',
});
const template = templateQuery.data;
const templateFileQuery = useAppTemplateFile(templateValues.templateId, {
enabled: templateValues.type === 'app',
});
const [renderedFile, setRenderedFile] = useState<string>('');
useEffect(() => {
if (templateFileQuery.data) {
const newFile = renderTemplate(
templateFileQuery.data,
templateValues.variables,
[]
);
if (newFile !== renderedFile) {
setRenderedFile(newFile);
setValues((values) => ({
...values,
fileContent: newFile,
}));
}
}
}, [
renderedFile,
setValues,
template,
templateFileQuery.data,
templateValues.variables,
]);
const [currentTemplateId, setCurrentTemplateId] = useState<
number | undefined
>(templateValues.templateId);
useEffect(() => {
if (template?.Id !== currentTemplateId) {
setCurrentTemplateId(template?.Id);
setValues((values) => ({
...values,
...getValuesFromAppTemplate(template),
}));
}
}, [currentTemplateId, setValues, template]);
return {
appTemplate: template,
isInitialLoading:
templateQuery.isInitialLoading || templateFileQuery.isInitialLoading,
};
}
function getValuesFromAppTemplate(
template: TemplateViewModel | undefined
): Partial<FormValues> {
if (!template) {
return {};
}
return {
deploymentType: DeploymentType.Compose,
...(template
? {
prePullImage: false,
retryDeploy: false,
staggerConfig: getDefaultStaggerConfig(),
}
: {}),
};
}

View file

@ -12,7 +12,7 @@ import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
import { DockerFormValues, FormValues } from './types';
export function useRenderTemplate(
export function useRenderCustomTemplate(
templateValues: DockerFormValues['templateValues'],
setValues: (values: SetStateAction<DockerFormValues>) => void
) {
@ -69,7 +69,11 @@ export function useRenderTemplate(
}
}, [currentTemplateId, setValues, template]);
return template;
return {
customTemplate: template,
isInitialLoading:
templateQuery.isInitialLoading || templateFileQuery.isInitialLoading,
};
}
function getValuesFromTemplate(