1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

feat(edge/templates): introduce edge app templates [EE-6209] (#10480)

This commit is contained in:
Chaim Lev-Ari 2023-11-14 14:54:44 +02:00 committed by GitHub
parent 95d96e1164
commit e1e90c9c1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1142 additions and 365 deletions

View file

@ -0,0 +1,50 @@
import { FormikErrors } from 'formik';
import { SchemaOf, string } from 'yup';
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { EdgeStack } from '../types';
export function NameField({
onChange,
value,
errors,
}: {
onChange(value: string): void;
value: string;
errors?: FormikErrors<string>;
}) {
return (
<FormControl inputId="name-input" label="Name" errors={errors} required>
<Input
id="name-input"
onChange={(e) => onChange(e.target.value)}
value={value}
required
/>
</FormControl>
);
}
export function nameValidation(
stacks: Array<EdgeStack>,
isComposeStack: boolean | undefined
): SchemaOf<string> {
let schema = string()
.required('Name is required')
.test('unique', 'Name should be unique', (value) =>
stacks.every((s) => s.Name !== value)
);
if (isComposeStack) {
schema = schema.matches(
new RegExp(STACK_NAME_VALIDATION_REGEX),
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
);
}
return schema;
}

View file

@ -2,7 +2,7 @@ import _ from 'lodash';
import { notifyError } from '@/portainer/services/notifications';
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
import { useCreateStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateStackFromFileContent';
import { useCreateEdgeStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { FormValues } from './types';
@ -24,7 +24,7 @@ export function PrivateRegistryFieldsetWrapper({
stackName: string;
onFieldError: (message: string) => void;
}) {
const dryRunMutation = useCreateStackFromFileContent();
const dryRunMutation = useCreateEdgeStackFromFileContent();
const registriesQuery = useRegistries();

View file

@ -18,6 +18,7 @@ interface Props {
error?: string | string[];
horizontal?: boolean;
isGroupVisible?(group: EdgeGroup): boolean;
required?: boolean;
}
export function EdgeGroupsSelector({
@ -26,6 +27,7 @@ export function EdgeGroupsSelector({
error,
horizontal,
isGroupVisible = () => true,
required,
}: Props) {
const selector = (
<InnerSelector
@ -36,11 +38,11 @@ export function EdgeGroupsSelector({
);
return horizontal ? (
<FormControl errors={error} label="Edge Groups">
<FormControl errors={error} label="Edge Groups" required={required}>
{selector}
</FormControl>
) : (
<FormSection title="Edge Groups">
<FormSection title={`Edge Groups${required ? ' *' : ''}`}>
<div className="form-group">
<div className="col-sm-12">{selector} </div>
{error && (

View file

@ -1,17 +1,21 @@
import { useMutation } from 'react-query';
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { RegistryId } from '@/react/portainer/registries/types';
import { EdgeGroup } from '../../edge-groups/types';
import { DeploymentType, EdgeStack } from '../types';
import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys';
export function useCreateStackFromFileContent() {
return useMutation(createStackFromFileContent, {
export function useCreateEdgeStackFromFileContent() {
const queryClient = useQueryClient();
return useMutation(createEdgeStackFromFileContent, {
...withError('Failed creating Edge stack'),
...withInvalidate(queryClient, [queryKeys.base()]),
});
}
@ -26,7 +30,7 @@ interface FileContentPayload {
dryRun?: boolean;
}
export async function createStackFromFileContent({
export async function createEdgeStackFromFileContent({
dryRun,
...payload
}: FileContentPayload) {

View file

@ -0,0 +1,142 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { Pair } from '@/react/portainer/settings/types';
import { RegistryId } from '@/react/portainer/registries/types';
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { DeploymentType, EdgeStack } from '../types';
import { EdgeGroup } from '../../edge-groups/types';
import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys';
export function useCreateEdgeStackFromGit() {
const queryClient = useQueryClient();
return useMutation(createEdgeStackFromGit, {
...withError('Failed creating Edge stack'),
...withInvalidate(queryClient, [queryKeys.base()]),
});
}
/**
* Represents the payload for creating an edge stack from a Git repository.
*/
interface GitPayload {
/** Name of the stack. */
name: string;
/** URL of a Git repository hosting the Stack file. */
repositoryURL: string;
/** Reference name of a Git repository hosting the Stack file. */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository. */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the bound git credential. */
repositoryGitCredentialID?: GitCredential['id'];
/** Path to the Stack file inside the Git repository. */
filePathInRepository?: string;
/** List of identifiers of EdgeGroups. */
edgeGroups: Array<EdgeGroup['Id']>;
/** Deployment type to deploy this stack. */
deploymentType: DeploymentType;
/** List of Registries to use for this stack. */
registries?: RegistryId[];
/** Uses the manifest's namespaces instead of the default one. */
useManifestNamespaces?: boolean;
/** Pre-pull image. */
prePullImage?: boolean;
/** Retry deploy. */
retryDeploy?: boolean;
/** TLSSkipVerify skips SSL verification when cloning the Git repository. */
tLSSkipVerify?: boolean;
/** Optional GitOps update configuration. */
autoUpdate?: AutoUpdateModel;
/** Whether the stack supports relative path volume. */
supportRelativePath?: boolean;
/** Local filesystem path. */
filesystemPath?: string;
/** Whether the edge stack supports per device configs. */
supportPerDeviceConfigs?: boolean;
/** Per device configs match type. */
perDeviceConfigsMatchType?: 'file' | 'dir';
/** Per device configs group match type. */
perDeviceConfigsGroupMatchType?: 'file' | 'dir';
/** Per device configs path. */
perDeviceConfigsPath?: string;
/** List of environment variables. */
envVars?: Pair[];
/** Configuration for stagger updates. */
staggerConfig?: EdgeStaggerConfig;
}
/**
* Represents the staggered updates configuration.
*/
interface EdgeStaggerConfig {
/** Stagger option for updates. */
staggerOption: EdgeStaggerOption;
/** Stagger parallel option for updates. */
staggerParallelOption: EdgeStaggerParallelOption;
/** Device number for updates. */
deviceNumber: number;
/** Starting device number for updates. */
deviceNumberStartFrom: number;
/** Increment value for device numbers during updates. */
deviceNumberIncrementBy: number;
/** Timeout for updates (in minutes). */
timeout: string;
/** Update delay (in minutes). */
updateDelay: string;
/** Action to take in case of update failure. */
updateFailureAction: EdgeUpdateFailureAction;
}
/** EdgeStaggerOption represents an Edge stack stagger option */
enum EdgeStaggerOption {
/** AllAtOnce represents a staggered deployment where all nodes are updated at once */
AllAtOnce = 1,
/** OneByOne represents a staggered deployment where nodes are updated with parallel setting */
Parallel,
}
/** EdgeStaggerParallelOption represents an Edge stack stagger parallel option */
enum EdgeStaggerParallelOption {
/** Fixed represents a staggered deployment where nodes are updated with a fixed number of nodes in parallel */
Fixed = 1,
/** Incremental represents a staggered deployment where nodes are updated with an incremental number of nodes in parallel */
Incremental,
}
/** EdgeUpdateFailureAction represents an Edge stack update failure action */
enum EdgeUpdateFailureAction {
/** Continue represents that stagger update will continue regardless of whether the endpoint update status */
Continue = 1,
/** Pause represents that stagger update will pause when the endpoint update status is failed */
Pause,
/** Rollback represents that stagger update will rollback as long as one endpoint update status is failed */
Rollback,
}
export async function createEdgeStackFromGit({
dryRun,
...payload
}: GitPayload & { dryRun?: boolean }) {
try {
const { data } = await axios.post<EdgeStack>(
buildUrl(undefined, 'create/repository'),
payload,
{
params: { dryrun: dryRun ? 'true' : 'false' },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,41 @@
import { useParamState } from '@/react/hooks/useParamState';
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { PageHeader } from '@@/PageHeader';
import { DeployFormWidget } from './DeployForm';
export function AppTemplatesView() {
const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
'template',
(param) => (param ? parseInt(param, 10) : 0)
);
const templatesQuery = useAppTemplates();
const selectedTemplate = selectedTemplateId
? templatesQuery.data?.find(
(template) => template.Id === selectedTemplateId
)
: undefined;
return (
<>
<PageHeader title="Application templates list" breadcrumbs="Templates" />
{selectedTemplate && (
<DeployFormWidget
template={selectedTemplate}
unselect={() => setSelectedTemplateId()}
/>
)}
<AppTemplatesList
templates={templatesQuery.data}
selectedId={selectedTemplateId}
onSelect={(template) => setSelectedTemplateId(template.Id)}
disabledTypes={[TemplateType.Container]}
fixedCategories={['edge']}
hideDuplicate
/>
</>
);
}

View file

@ -0,0 +1,190 @@
import { Rocket } from 'lucide-react';
import { Form, Formik } from 'formik';
import { array, lazy, number, object, string } from 'yup';
import { useRouter } from '@uirouter/react';
import _ from 'lodash';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { FallbackImage } from '@@/FallbackImage';
import { Icon } from '@@/Icon';
import { FormActions } from '@@/form-components/FormActions';
import { Button } from '@@/buttons';
import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector';
import {
NameField,
nameValidation,
} from '../../edge-stacks/CreateView/NameField';
import { EdgeGroup } from '../../edge-groups/types';
import { DeploymentType, EdgeStack } from '../../edge-stacks/types';
import { useEdgeStacks } from '../../edge-stacks/queries/useEdgeStacks';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
import { useCreateEdgeStackFromGit } from '../../edge-stacks/queries/useCreateEdgeStackFromGit';
import { EnvVarsFieldset } from './EnvVarsFieldset';
export function DeployFormWidget({
template,
unselect,
}: {
template: TemplateViewModel;
unselect: () => void;
}) {
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title
icon={
<FallbackImage
src={template.Logo}
fallbackIcon={<Icon icon={Rocket} />}
/>
}
title={template.Title}
/>
<Widget.Body>
<DeployForm template={template} unselect={unselect} />
</Widget.Body>
</Widget>
</div>
</div>
);
}
interface FormValues {
name: string;
edgeGroupIds: Array<EdgeGroup['Id']>;
envVars: Record<string, string>;
}
function DeployForm({
template,
unselect,
}: {
template: TemplateViewModel;
unselect: () => void;
}) {
const router = useRouter();
const mutation = useCreateEdgeStackFromGit();
const edgeStacksQuery = useEdgeStacks();
const edgeGroupsQuery = useEdgeGroups({
select: (groups) =>
Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
});
const initialValues: FormValues = {
edgeGroupIds: [],
name: template.Name || '',
envVars:
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
{},
};
if (!edgeStacksQuery.data || !edgeGroupsQuery.data) {
return null;
}
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={() =>
validation(edgeStacksQuery.data, edgeGroupsQuery.data)
}
validateOnMount
>
{({ values, errors, setFieldValue, isValid }) => (
<Form className="form-horizontal">
<NameField
value={values.name}
onChange={(v) => setFieldValue('name', v)}
errors={errors.name}
/>
<EdgeGroupsSelector
horizontal
value={values.edgeGroupIds}
error={errors.edgeGroupIds}
onChange={(value) => setFieldValue('edgeGroupIds', value)}
required
/>
<EnvVarsFieldset
value={values.envVars}
options={template.Env}
errors={errors.envVars}
onChange={(values) => setFieldValue('envVars', values)}
/>
<FormActions
isLoading={mutation.isLoading}
isValid={isValid}
loadingText="Deployment in progress..."
submitLabel="Deploy the stack"
>
<Button type="reset" onClick={() => unselect()} color="default">
Hide
</Button>
</FormActions>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
return mutation.mutate(
{
name: values.name,
edgeGroups: values.edgeGroupIds,
deploymentType: DeploymentType.Compose,
repositoryURL: template.Repository.url,
filePathInRepository: template.Repository.stackfile,
envVars: Object.entries(values.envVars).map(([name, value]) => ({
name,
value,
})),
},
{
onSuccess() {
notifySuccess('Success', 'Edge Stack created');
router.stateService.go('edge.stacks');
},
}
);
}
}
function validation(
stacks: EdgeStack[],
edgeGroupsType: Record<EdgeGroup['Id'], Array<EnvironmentType>>
) {
return lazy((values: FormValues) => {
const types = getTypes(values.edgeGroupIds);
return object({
name: nameValidation(
stacks,
types?.includes(EnvironmentType.EdgeAgentOnDocker)
),
edgeGroupIds: array(number().required().default(0))
.min(1, 'At least one group is required')
.test(
'same-type',
'Groups should be of the same type',
(value) => _.uniq(getTypes(value)).length === 1
),
envVars: array()
.transform((_, orig) => Object.values(orig))
.of(string().required('Required')),
});
});
function getTypes(value: number[] | undefined) {
return value?.flatMap((g) => edgeGroupsType[g]);
}
}

View file

@ -0,0 +1,76 @@
import { FormikErrors } from 'formik';
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
type Value = Record<string, string>;
export function EnvVarsFieldset({
onChange,
options,
value,
errors,
}: {
options: Array<TemplateEnv>;
onChange: (value: Value) => void;
value: Value;
errors?: FormikErrors<Value>;
}) {
return (
<>
{options.map((env, index) => (
<Item
key={env.name}
option={env}
value={value[env.name]}
onChange={(value) => handleChange(env.name, value)}
errors={errors?.[index]}
/>
))}
</>
);
function handleChange(name: string, envValue: string) {
onChange({ ...value, [name]: envValue });
}
}
function Item({
onChange,
option,
value,
errors,
}: {
option: TemplateEnv;
value: string;
onChange: (value: string) => void;
errors?: FormikErrors<string>;
}) {
return (
<FormControl
label={option.label || option.name}
required={!option.preset}
errors={errors}
>
{option.select ? (
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
options={option.select.map((o) => ({
label: o.text,
value: o.value,
}))}
disabled={option.preset}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={option.preset}
/>
)}
</FormControl>
);
}

View file

@ -0,0 +1 @@
export { AppTemplatesView } from './AppTemplatesView';