fix(edge/templates): fix issues with git templates [EE-6357] (#10679)

pull/10761/head
Chaim Lev-Ari 2023-12-04 08:46:44 +02:00 committed by GitHub
parent 974378c9b5
commit 2a18c9f215
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 201 additions and 116 deletions

View File

@ -112,8 +112,7 @@ export default class CreateEdgeStackViewController {
PrePullImage: template.EdgeSettings.PrePullImage || false, PrePullImage: template.EdgeSettings.PrePullImage || false,
RetryDeploy: template.EdgeSettings.RetryDeploy || false, RetryDeploy: template.EdgeSettings.RetryDeploy || false,
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null, PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
SupportRelativePath: template.EdgeSettings.RelativePathSettings.SupportRelativePath || false, ...template.EdgeSettings.RelativePathSettings,
FilesystemPath: template.EdgeSettings.RelativePathSettings.FilesystemPath || '',
} }
: {}), : {}),
}; };
@ -195,11 +194,7 @@ export default class CreateEdgeStackViewController {
createStack() { createStack() {
return this.$async(async () => { return this.$async(async () => {
const name = this.formValues.Name; const name = this.formValues.Name;
let method = this.state.Method; const method = getMethod(this.state.Method, this.state.templateValues.template);
if (method === 'template') {
method = 'editor';
}
if (!this.validateForm(method)) { if (!this.validateForm(method)) {
return; return;
@ -338,3 +333,20 @@ export default class CreateEdgeStackViewController {
); );
} }
} }
/**
*
* @param {'template'|'repository' | 'editor' | 'upload'} method
* @param {import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined} template
* @returns 'repository' | 'editor' | 'upload'
*/
function getMethod(method, template) {
if (method !== 'template') {
return method;
}
if (template && template.GitConfig) {
return 'repository';
}
return 'editor';
}

View File

@ -12,6 +12,11 @@ class DockerComposeFormController {
this.onChangeFile = this.onChangeFile.bind(this); this.onChangeFile = this.onChangeFile.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this); this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.isGitTemplate = this.isGitTemplate.bind(this);
}
isGitTemplate() {
return this.state.Method === 'template' && !!this.templateValues.template && !!this.templateValues.template.GitConfig;
} }
onChangeFormValues(newValues) { onChangeFormValues(newValues) {

View File

@ -15,7 +15,8 @@
ng-required="true" ng-required="true"
yml="true" yml="true"
placeholder="Define or paste the content of your docker compose file here" placeholder="Define or paste the content of your docker compose file here"
read-only="$ctrl.state.Method === 'template' && $ctrl.template.GitConfig" versions="$ctrl.formValues.versions"
read-only="$ctrl.isGitTemplate()"
> >
<editor-description> <editor-description>
You can get more information about Compose file format in the You can get more information about Compose file format in the
@ -28,7 +29,7 @@
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description> <file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
</file-upload-form> </file-upload-form>
<div ng-if="$ctrl.state.Method == 'repository'"> <div ng-if="$ctrl.state.Method == 'repository' || $ctrl.isGitTemplate()">
<git-form <git-form
value="$ctrl.formValues" value="$ctrl.formValues"
on-change="($ctrl.onChangeFormValues)" on-change="($ctrl.onChangeFormValues)"

View File

@ -27,14 +27,13 @@ import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types'; import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector'; import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
import { useCurrentUser } from '@/react/hooks/useUser'; import { notifySuccess } from '@/portainer/services/notifications';
import { useCreateGitCredentialMutation } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { EnvironmentType } from '@/react/portainer/environments/types'; import { EnvironmentType } from '@/react/portainer/environments/types';
import { Registry } from '@/react/portainer/registries/types'; import { Registry } from '@/react/portainer/registries/types';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries'; import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset'; import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { parseRelativePathResponse } from '@/react/portainer/gitops/RelativePathFieldset/utils'; import { parseRelativePathResponse } from '@/react/portainer/gitops/RelativePathFieldset/utils';
import { useSaveCredentialsIfRequired } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { LoadingButton } from '@@/buttons'; import { LoadingButton } from '@@/buttons';
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
@ -65,8 +64,8 @@ interface FormValues {
export function GitForm({ stack }: { stack: EdgeStack }) { export function GitForm({ stack }: { stack: EdgeStack }) {
const router = useRouter(); const router = useRouter();
const updateStackMutation = useUpdateEdgeStackGitMutation(); const updateStackMutation = useUpdateEdgeStackGitMutation();
const saveCredentialsMutation = useCreateGitCredentialMutation(); const { saveCredentials, isLoading: isSaveCredentialsLoading } =
const { user } = useCurrentUser(); useSaveCredentialsIfRequired();
if (!stack.GitConfig) { if (!stack.GitConfig) {
return null; return null;
@ -95,7 +94,9 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
onUpdateSettingsClick={handleUpdateSettings} onUpdateSettingsClick={handleUpdateSettings}
gitPath={gitConfig.ConfigFilePath} gitPath={gitConfig.ConfigFilePath}
gitUrl={gitConfig.URL} gitUrl={gitConfig.URL}
isLoading={updateStackMutation.isLoading} isLoading={
updateStackMutation.isLoading || isSaveCredentialsLoading
}
isUpdateVersion={!!updateStackMutation.variables?.updateVersion} isUpdateVersion={!!updateStackMutation.variables?.updateVersion}
/> />
); );
@ -105,9 +106,7 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
return; return;
} }
const credentialId = await saveCredentialsIfRequired( const credentialId = await saveCredentials(values.authentication);
values.authentication
);
updateStackMutation.mutate(getPayload(values, credentialId, false), { updateStackMutation.mutate(getPayload(values, credentialId, false), {
onSuccess() { onSuccess() {
@ -121,7 +120,7 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
); );
async function handleSubmit(values: FormValues) { async function handleSubmit(values: FormValues) {
const credentialId = await saveCredentialsIfRequired(values.authentication); const credentialId = await saveCredentials(values.authentication);
updateStackMutation.mutate(getPayload(values, credentialId, true), { updateStackMutation.mutate(getPayload(values, credentialId, true), {
onSuccess() { onSuccess() {
@ -151,29 +150,6 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
...values, ...values,
}; };
} }
async function saveCredentialsIfRequired(authentication: GitAuthModel) {
if (
!authentication.SaveCredential ||
!authentication.RepositoryPassword ||
!authentication.NewCredentialName
) {
return authentication.RepositoryGitCredentialID;
}
try {
const credential = await saveCredentialsMutation.mutateAsync({
userId: user.Id,
username: authentication.RepositoryUsername,
password: authentication.RepositoryPassword,
name: authentication.NewCredentialName,
});
return credential.id;
} catch (err) {
notifyError('Error', err as Error, 'Unable to save credentials');
return undefined;
}
}
} }
function InnerForm({ function InnerForm({

View File

@ -29,7 +29,7 @@ export function PrivateRegistryFieldsetWrapper({
}) { }) {
const dryRunMutation = useParseRegistries(); const dryRunMutation = useParseRegistries();
const registriesQuery = useRegistries(); const registriesQuery = useRegistries({ hideDefault: true });
if (!registriesQuery.data) { if (!registriesQuery.data) {
return null; return null;

View File

@ -7,9 +7,12 @@ import { useCreateTemplateMutation } from '@/react/portainer/templates/custom-te
import { Platform } from '@/react/portainer/templates/types'; import { Platform } from '@/react/portainer/templates/types';
import { useFetchTemplateFile } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile'; import { useFetchTemplateFile } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
import { getDefaultEdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; import { getDefaultEdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
import { useSaveCredentialsIfRequired } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { editor } from '@@/BoxSelector/common-options/build-methods'; import { editor } from '@@/BoxSelector/common-options/build-methods';
import { toGitRequest } from '../common/git';
import { InnerForm } from './InnerForm'; import { InnerForm } from './InnerForm';
import { FormValues } from './types'; import { FormValues } from './types';
import { useValidation } from './useValidation'; import { useValidation } from './useValidation';
@ -19,6 +22,8 @@ export function CreateTemplateForm() {
const mutation = useCreateTemplateMutation(); const mutation = useCreateTemplateMutation();
const validation = useValidation(); const validation = useValidation();
const { appTemplateId, type } = useParams(); const { appTemplateId, type } = useParams();
const { saveCredentials, isLoading: isSaveCredentialsLoading } =
useSaveCredentialsIfRequired();
const fileContentQuery = useFetchTemplateFile(appTemplateId); const fileContentQuery = useFetchTemplateFile(appTemplateId);
@ -58,13 +63,19 @@ export function CreateTemplateForm() {
validationSchema={validation} validationSchema={validation}
validateOnMount validateOnMount
> >
<InnerForm isLoading={mutation.isLoading} /> <InnerForm isLoading={mutation.isLoading || isSaveCredentialsLoading} />
</Formik> </Formik>
); );
function handleSubmit(values: FormValues) { async function handleSubmit(values: FormValues) {
const credentialId = await saveCredentials(values.Git);
mutation.mutate( mutation.mutate(
{ ...values, EdgeTemplate: true }, {
...values,
EdgeTemplate: true,
Git: toGitRequest(values.Git, credentialId),
},
{ {
onSuccess() { onSuccess() {
notifySuccess('Success', 'Template created'); notifySuccess('Success', 'Template created');

View File

@ -42,7 +42,7 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
usePreventExit( usePreventExit(
initialValues.FileContent, initialValues.FileContent,
values.FileContent, values.FileContent,
values.Method === editor.value && !isSubmitting values.Method === editor.value && !isSubmitting && !isLoading
); );
const isGit = values.Method === git.value; const isGit = values.Method === git.value;
@ -108,16 +108,7 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
/> />
)} )}
{isTemplateVariablesEnabled && ( {isGit && (
<CustomTemplatesVariablesDefinitionField
value={values.Variables}
onChange={(values) => setFieldValue('Variables', values)}
isVariablesNamesFromParent={values.Method === editor.value}
errors={errors.Variables}
/>
)}
{values.Method === git.value && (
<GitForm <GitForm
value={values.Git} value={values.Git}
onChange={(newValues) => onChange={(newValues) =>
@ -130,6 +121,15 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
/> />
)} )}
{isTemplateVariablesEnabled && (
<CustomTemplatesVariablesDefinitionField
value={values.Variables}
onChange={(values) => setFieldValue('Variables', values)}
isVariablesNamesFromParent={values.Method === editor.value}
errors={errors.Variables}
/>
)}
{values.EdgeSettings && ( {values.EdgeSettings && (
<EdgeSettingsFieldset <EdgeSettingsFieldset
setValues={(edgeSetValues) => setValues={(edgeSetValues) =>

View File

@ -11,6 +11,9 @@ import {
isTemplateVariablesEnabled, isTemplateVariablesEnabled,
} from '@/react/portainer/custom-templates/components/utils'; } from '@/react/portainer/custom-templates/components/utils';
import { toGitFormModel } from '@/react/portainer/gitops/types'; import { toGitFormModel } from '@/react/portainer/gitops/types';
import { useSaveCredentialsIfRequired } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { toGitRequest } from '../common/git';
import { InnerForm } from './InnerForm'; import { InnerForm } from './InnerForm';
import { FormValues } from './types'; import { FormValues } from './types';
@ -22,6 +25,8 @@ export function EditTemplateForm({ template }: { template: CustomTemplate }) {
const isGit = !!template.GitConfig; const isGit = !!template.GitConfig;
const validation = useValidation(template.Id, isGit); const validation = useValidation(template.Id, isGit);
const fileQuery = useCustomTemplateFile(template.Id, isGit); const fileQuery = useCustomTemplateFile(template.Id, isGit);
const { saveCredentials, isLoading: isSaveCredentialsLoading } =
useSaveCredentialsIfRequired();
if (fileQuery.isLoading) { if (fileQuery.isLoading) {
return null; return null;
@ -49,7 +54,7 @@ export function EditTemplateForm({ template }: { template: CustomTemplate }) {
validateOnMount validateOnMount
> >
<InnerForm <InnerForm
isLoading={mutation.isLoading} isLoading={mutation.isLoading || isSaveCredentialsLoading}
isEditorReadonly={isGit} isEditorReadonly={isGit}
gitFileContent={isGit ? fileQuery.data : ''} gitFileContent={isGit ? fileQuery.data : ''}
refreshGitFile={fileQuery.refetch} refreshGitFile={fileQuery.refetch}
@ -60,7 +65,9 @@ export function EditTemplateForm({ template }: { template: CustomTemplate }) {
</Formik> </Formik>
); );
function handleSubmit(values: FormValues) { async function handleSubmit(values: FormValues) {
const credentialId = await saveCredentials(values.Git);
mutation.mutate( mutation.mutate(
{ {
id: template.Id, id: template.Id,
@ -74,7 +81,7 @@ export function EditTemplateForm({ template }: { template: CustomTemplate }) {
Platform: values.Platform, Platform: values.Platform,
Variables: values.Variables, Variables: values.Variables,
EdgeSettings: values.EdgeSettings, EdgeSettings: values.EdgeSettings,
...values.Git, ...(values.Git ? toGitRequest(values.Git, credentialId) : {}),
}, },
{ {
onSuccess() { onSuccess() {

View File

@ -51,7 +51,7 @@ export function InnerForm({
usePreventExit( usePreventExit(
initialValues.FileContent, initialValues.FileContent,
values.FileContent, values.FileContent,
!isEditorReadonly && !isSubmitting !isEditorReadonly && !isSubmitting && !isLoading
); );
return ( return (
<Form className="form-horizontal"> <Form className="form-horizontal">

View File

@ -0,0 +1,35 @@
import { transformGitAuthenticationViewModel } from '@/react/portainer/gitops/AuthFieldset/utils';
import { GitFormModel } from '@/react/portainer/gitops/types';
export function toGitRequest(
gitConfig: GitFormModel,
credentialId: number | undefined
): GitFormModel {
return {
...gitConfig,
...getGitAuthValues(gitConfig, credentialId),
};
}
function getGitAuthValues(
gitConfig: GitFormModel | undefined,
credentialId: number | undefined
) {
if (!credentialId) {
return gitConfig;
}
const authModel = transformGitAuthenticationViewModel({
...gitConfig,
RepositoryGitCredentialID: credentialId,
});
return authModel
? {
RepositoryAuthentication: true,
RepositoryGitCredentialID: authModel.GitCredentialID,
RepositoryPassword: authModel.Password,
RepositoryUsername: authModel.Username,
}
: {};
}

View File

@ -6,25 +6,7 @@ import { UserId } from '@/portainer/users/types';
import { isBE } from '../../feature-flags/feature-flags.service'; import { isBE } from '../../feature-flags/feature-flags.service';
import { import { GitCredential, UpdateGitCredentialPayload } from './types';
CreateGitCredentialPayload,
GitCredential,
UpdateGitCredentialPayload,
} from './types';
export async function createGitCredential(
gitCredential: CreateGitCredentialPayload
) {
try {
const { data } = await axios.post<{ gitCredential: GitCredential }>(
buildGitUrl(gitCredential.userId),
gitCredential
);
return data.gitCredential;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create git credential');
}
}
export async function getGitCredentials(userId: number) { export async function getGitCredentials(userId: number) {
try { try {
@ -141,24 +123,7 @@ export function useGitCredential(userId: number, id: number) {
}); });
} }
export function useCreateGitCredentialMutation() { export function buildGitUrl(userId: number, credentialId?: number) {
const queryClient = useQueryClient();
return useMutation(createGitCredential, {
onSuccess: (_, payload) => {
notifySuccess('Credentials created successfully', payload.name);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to create credential',
},
},
});
}
function buildGitUrl(userId: number, credentialId?: number) {
return credentialId return credentialId
? `/users/${userId}/gitcredentials/${credentialId}` ? `/users/${userId}/gitcredentials/${credentialId}`
: `/users/${userId}/gitcredentials`; : `/users/${userId}/gitcredentials`;

View File

@ -0,0 +1,82 @@
import { useQueryClient, useMutation } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { useCurrentUser } from '@/react/hooks/useUser';
import { GitCredential } from '../types';
import { buildGitUrl } from '../git-credentials.service';
export interface CreateGitCredentialPayload {
userId: number;
name: string;
username?: string;
password: string;
}
export function useCreateGitCredentialMutation() {
const queryClient = useQueryClient();
return useMutation(createGitCredential, {
onSuccess: (_, payload) => {
notifySuccess('Credentials created successfully', payload.name);
return queryClient.invalidateQueries(['gitcredentials']);
},
meta: {
error: {
title: 'Failure',
message: 'Unable to create credential',
},
},
});
}
async function createGitCredential(gitCredential: CreateGitCredentialPayload) {
try {
const { data } = await axios.post<{ gitCredential: GitCredential }>(
buildGitUrl(gitCredential.userId),
gitCredential
);
return data.gitCredential;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create git credential');
}
}
export function useSaveCredentialsIfRequired() {
const saveCredentialsMutation = useCreateGitCredentialMutation();
const { user } = useCurrentUser();
return {
saveCredentials: saveCredentialsIfRequired,
isLoading: saveCredentialsMutation.isLoading,
};
async function saveCredentialsIfRequired(authentication?: GitAuthModel) {
if (!authentication) {
return undefined;
}
if (
!authentication.SaveCredential ||
!authentication.RepositoryPassword ||
!authentication.NewCredentialName
) {
return authentication.RepositoryGitCredentialID;
}
try {
const credential = await saveCredentialsMutation.mutateAsync({
userId: user.Id,
username: authentication.RepositoryUsername,
password: authentication.RepositoryPassword,
name: authentication.NewCredentialName,
});
return credential.id;
} catch (err) {
notifyError('Error', err as Error, 'Unable to save credentials');
return undefined;
}
}
}

View File

@ -13,13 +13,6 @@ export interface GitCredentialFormValues {
password: string; password: string;
} }
export interface CreateGitCredentialPayload {
userId: number;
name: string;
username?: string;
password: string;
}
export interface UpdateGitCredentialPayload { export interface UpdateGitCredentialPayload {
name: string; name: string;
username?: string; username?: string;

View File

@ -35,7 +35,7 @@ export function RelativePathFieldset({
const { errors } = useValidation(value); const { errors } = useValidation(value);
const { enableFsPath0, enableFsPath1, toggleFsPath } = useEnableFsPath(); const { enableFsPath0, enableFsPath1, toggleFsPath } = useEnableFsPath(value);
const pathTip0 = const pathTip0 =
'For relative path volumes use with Docker Swarm, you must have a network filesystem which all of your nodes can access.'; 'For relative path volumes use with Docker Swarm, you must have a network filesystem which all of your nodes can access.';

View File

@ -1,7 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
export function useEnableFsPath() { import { RelativePathModel } from './types';
const [state, setState] = useState<number[]>([]);
export function useEnableFsPath(initialValue: RelativePathModel) {
const [state, setState] = useState<number[]>(() =>
initialValue.SupportPerDeviceConfigs ? [1] : []
);
const enableFsPath0 = state.length && state[0] === 0; const enableFsPath0 = state.length && state[0] === 0;
const enableFsPath1 = state.length && state[0] === 1; const enableFsPath1 = state.length && state[0] === 1;

View File

@ -9,11 +9,7 @@ import { usePublicSettings } from '../../settings/queries';
import { queryKeys } from './query-keys'; import { queryKeys } from './query-keys';
export function useRegistries<T = Registry[]>( export function useRegistries<T = Registry[]>(
queryOptions: { queryOptions: GenericRegistriesQueryOptions<T> = {}
enabled?: boolean;
select?: (registries: Registry[]) => T;
onSuccess?: (data: T) => void;
} = {}
) { ) {
return useGenericRegistriesQuery( return useGenericRegistriesQuery(
queryKeys.base(), queryKeys.base(),
@ -22,13 +18,11 @@ export function useRegistries<T = Registry[]>(
); );
} }
/**
* @field hideDefault - is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this.
*/
export type GenericRegistriesQueryOptions<T> = { export type GenericRegistriesQueryOptions<T> = {
enabled?: boolean; enabled?: boolean;
select?: (registries: Registry[]) => T; select?: (registries: Registry[]) => T;
onSuccess?: (data: T) => void; onSuccess?: (data: T) => void;
/** is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this. */
hideDefault?: boolean; hideDefault?: boolean;
}; };