From ee8e73d7f9ebf8d11fce807f69e090bfaed5fadf Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 16 Jun 2026 17:33:19 +0300 Subject: [PATCH] feat(edge/stacks): use source ID for edge stack git creation [BE-13044] (#2926) Co-authored-by: Claude Sonnet 4.6 --- api/gitops/sources/repo_config.go | 55 +++++++++++++++ api/gitops/sources/repo_config_test.go | 70 +++++++++++++++++++ .../edgestacks/edgestack_create_git.go | 52 ++++++++------ .../CreateView/CreateForm.validation.ts | 4 +- .../CreateView/DockerComposeForm.tsx | 1 + .../CreateView/KubeManifestForm.tsx | 1 + .../edge/edge-stacks/CreateView/useCreate.tsx | 19 ++--- .../useCreateEdgeStack/createStackFromGit.ts | 4 +- .../useCreateEdgeStack/useCreateEdgeStack.ts | 7 +- .../generated-api/portainer/types.gen.ts | 17 +++-- .../generated-api/portainer/zod.gen.ts | 3 +- .../gitops/sources/GitSourceSelector.tsx | 8 ++- 12 files changed, 193 insertions(+), 48 deletions(-) create mode 100644 api/gitops/sources/repo_config.go create mode 100644 api/gitops/sources/repo_config_test.go diff --git a/api/gitops/sources/repo_config.go b/api/gitops/sources/repo_config.go new file mode 100644 index 0000000000..6e333f58e9 --- /dev/null +++ b/api/gitops/sources/repo_config.go @@ -0,0 +1,55 @@ +package sources + +import ( + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/pkg/fips" + httperror "github.com/portainer/portainer/pkg/libhttp/error" +) + +// RepoConfigInput holds the raw payload fields needed to resolve a git RepoConfig. +// Set SourceID to resolve URL/auth from a stored source; otherwise provide the inline fields. +type RepoConfigInput struct { + SourceID portainer.SourceID + ReferenceName string + ConfigFilePath string + RepositoryURL string + TLSSkipVerify bool + RepositoryAuthentication bool + Username string + Password string + Provider gittypes.GitProvider + AuthorizationType gittypes.GitCredentialAuthType +} + +// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields. +func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) { + cfg := gittypes.RepoConfig{ + ReferenceName: input.ReferenceName, + ConfigFilePath: input.ConfigFilePath, + } + + if input.SourceID != 0 { + src, httpErr := ValidateGitSourceAccess(tx, input.SourceID) + if httpErr != nil { + return gittypes.RepoConfig{}, httpErr + } + cfg.URL = src.Git.URL + cfg.Authentication = src.Git.Authentication + cfg.TLSSkipVerify = src.Git.TLSSkipVerify + } else { + cfg.URL = input.RepositoryURL + cfg.TLSSkipVerify = input.TLSSkipVerify + if input.RepositoryAuthentication { + cfg.Authentication = &gittypes.GitAuthentication{ + Username: input.Username, + Password: input.Password, + Provider: input.Provider, + AuthorizationType: input.AuthorizationType, + } + } + } + + cfg.TLSSkipVerify = cfg.TLSSkipVerify && fips.CanTLSSkipVerify() + return cfg, nil +} diff --git a/api/gitops/sources/repo_config_test.go b/api/gitops/sources/repo_config_test.go new file mode 100644 index 0000000000..f41ee51e85 --- /dev/null +++ b/api/gitops/sources/repo_config_test.go @@ -0,0 +1,70 @@ +package sources + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/pkg/fips" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + src := &portainer.Source{ + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{ + URL: "https://github.com/org/repo", + TLSSkipVerify: true, + Authentication: &gittypes.GitAuthentication{ + Username: "user", + Password: "token", + }, + }, + } + require.NoError(t, store.Source().Create(src)) + + cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{ + SourceID: src.ID, + ReferenceName: "refs/heads/main", + ConfigFilePath: "docker-compose.yml", + RepositoryURL: "https://ignored.example.com", + }) + + require.Nil(t, httpErr) + assert.Equal(t, src.Git.URL, cfg.URL) + assert.Equal(t, src.Git.Authentication, cfg.Authentication) + assert.Equal(t, src.Git.TLSSkipVerify, cfg.TLSSkipVerify) + assert.Equal(t, "refs/heads/main", cfg.ReferenceName) + assert.Equal(t, "docker-compose.yml", cfg.ConfigFilePath) +} + +func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, false) + + cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{ + ReferenceName: "refs/heads/main", + ConfigFilePath: "docker-compose.yml", + RepositoryURL: "https://github.com/org/repo", + TLSSkipVerify: true, + RepositoryAuthentication: true, + Username: "user", + Password: "pass", + }) + + require.Nil(t, httpErr) + assert.Equal(t, "https://github.com/org/repo", cfg.URL) + assert.True(t, cfg.TLSSkipVerify) + require.NotNil(t, cfg.Authentication) + assert.Equal(t, "user", cfg.Authentication.Username) + assert.Equal(t, "pass", cfg.Authentication.Password) +} diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go index 54ba2eeda4..09f902f0c6 100644 --- a/api/http/handler/edgestacks/edgestack_create_git.go +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/gitops/sources" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/pkg/edge" @@ -25,15 +26,18 @@ type edgeStackFromGitRepositoryPayload struct { // Name must start with a lowercase character or number // Example: stack-name or stack_123 or stackName Name string `example:"stack-name" validate:"required"` - // URL of a Git repository hosting the Stack file - RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"` + // SourceID references an existing Source for git credentials/URL. + // When set, the inline URL and authentication fields are ignored. + SourceID portainer.SourceID `example:"1"` + // Deprecated: Use SourceID instead. URL of a Git repository hosting the Stack file. + RepositoryURL string `example:"https://github.com/openfaas/faas"` // Reference name of a Git repository hosting the Stack file RepositoryReferenceName string `example:"refs/heads/master"` - // Use basic authentication to clone the Git repository + // Deprecated: Use SourceID instead. Use basic authentication to clone the Git repository. RepositoryAuthentication bool `example:"true"` - // Username used in basic authentication. Required when RepositoryAuthentication is true. + // Deprecated: Use SourceID instead. Username used in basic authentication. RepositoryUsername string `example:"myGitUsername"` - // Password used in basic authentication. Required when RepositoryAuthentication is true. + // Deprecated: Use SourceID instead. Password used in basic authentication. RepositoryPassword string `example:"myGitPassword"` // Path to the Stack file inside the Git repository FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` @@ -48,7 +52,7 @@ type edgeStackFromGitRepositoryPayload struct { Registries []portainer.RegistryID // Uses the manifest's namespaces instead of the default one UseManifestNamespaces bool - // TLSSkipVerify skips SSL verification when cloning the Git repository + // Deprecated: Use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. TLSSkipVerify bool `example:"false"` } @@ -61,12 +65,14 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number") } - if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { - return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") - } + if payload.SourceID == 0 { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { + return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") + } - if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { - return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled") + if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { + return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled") + } } if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes { @@ -118,18 +124,18 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat return stack, nil } - repoConfig := gittypes.RepoConfig{ - URL: payload.RepositoryURL, - ReferenceName: payload.RepositoryReferenceName, - ConfigFilePath: payload.FilePathInRepository, - TLSSkipVerify: payload.TLSSkipVerify, - } - - if payload.RepositoryAuthentication { - repoConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - } + repoConfig, httpErr := sources.ResolveRepoConfig(tx, sources.RepoConfigInput{ + SourceID: payload.SourceID, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.FilePathInRepository, + RepositoryURL: payload.RepositoryURL, + TLSSkipVerify: payload.TLSSkipVerify, + RepositoryAuthentication: payload.RepositoryAuthentication, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + }) + if httpErr != nil { + return nil, httpErr } stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID) diff --git a/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts b/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts index 3e7c04faf1..49f5e1de95 100644 --- a/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts +++ b/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts @@ -128,7 +128,9 @@ export function useValidation({ : 'manifest'; return buildGitValidationSchema( !!customTemplate, - deploymentMethod + deploymentMethod, + false, + true ); }, }) as SchemaOf, diff --git a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx index 608e97500a..8d9366e5c3 100644 --- a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx +++ b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx @@ -140,6 +140,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) { baseWebhookUrl={baseEdgeStackWebhookUrl()} webhookId={webhookId} isAutoUpdateVisible={isBE} + isSourceSelectionVisible /> {isBE && ( diff --git a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx index 4b2d3504f3..2cc674fa16 100644 --- a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx +++ b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx @@ -111,6 +111,7 @@ export function KubeManifestForm({ baseWebhookUrl={baseEdgeStackWebhookUrl()} webhookId={webhookId} isAutoUpdateVisible={isBE} + isSourceSelectionVisible /> )} diff --git a/app/react/edge/edge-stacks/CreateView/useCreate.tsx b/app/react/edge/edge-stacks/CreateView/useCreate.tsx index b7f2361ce7..35eaeeec9f 100644 --- a/app/react/edge/edge-stacks/CreateView/useCreate.tsx +++ b/app/react/edge/edge-stacks/CreateView/useCreate.tsx @@ -4,7 +4,6 @@ import { TemplateViewModel } from '@/react/portainer/templates/app-templates/vie import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; import { notifySuccess } from '@/portainer/services/notifications'; import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; -import { mutationOptions, withError } from '@/react-tools/react-query'; import { BasePayload, @@ -37,18 +36,12 @@ export function useCreate({ getIsGitTemplate(template, templateType) ); - mutation.mutate( - getPayload(method, values), - mutationOptions( - { - onSuccess: () => { - notifySuccess('Success', 'Edge stack created'); - router.stateService.go('^'); - }, - }, - withError('unable to create edge stack') - ) - ); + mutation.mutate(getPayload(method, values), { + onSuccess: () => { + notifySuccess('Success', 'Edge stack created'); + router.stateService.go('^'); + }, + }); function getPayload( method: 'string' | 'file' | 'git', diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts index a1fa29530c..9a144079f2 100644 --- a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts @@ -13,8 +13,10 @@ import { buildUrl } from '../buildUrl'; export type GitRepositoryPayload = { /** Name of the stack */ name: string; + /** ID of an existing git source to use for credentials/URL. When set, repositoryUrl and auth fields are ignored. */ + sourceId?: number; /** URL of a Git repository hosting the Stack file */ - repositoryUrl: string; + repositoryUrl?: string; /** Reference name of a Git repository hosting the Stack file */ repositoryReferenceName?: string; /** Use basic authentication to clone the Git repository */ diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts index 1e33a44f10..8aca06ddc6 100644 --- a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts @@ -8,6 +8,7 @@ import { GitFormModel, RelativePathModel, } from '@/react/portainer/gitops/types'; +import { withError } from '@/react-tools/react-query'; import { DeploymentType, StaggerConfig } from '../../types'; @@ -16,7 +17,10 @@ import { createStackFromFileContent } from './createStackFromFileContent'; import { createStackFromGit } from './createStackFromGit'; export function useCreateEdgeStack() { - return useMutation(createEdgeStack); + return useMutation({ + mutationFn: createEdgeStack, + ...withError('unable to create edge stack'), + }); } export type BasePayload = { @@ -126,6 +130,7 @@ function createEdgeStackFromGit( retryDeploy: payload.retryDeploy, staggerConfig: payload.staggerConfig, useManifestNamespaces: payload.useManifestNamespaces, + sourceId: payload.git.SourceId, repositoryUrl: payload.git.RepositoryURL, repositoryReferenceName: payload.git.RepositoryReferenceName, filePathInRepository: payload.git.ComposeFilePathInRepository, diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index a27ee7dcd6..faf61a8dbc 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -7858,11 +7858,11 @@ export type EdgestacksEdgeStackFromGitRepositoryPayload = { */ Registries?: Array; /** - * Use basic authentication to clone the Git repository + * Deprecated: Use SourceID instead. Use basic authentication to clone the Git repository. */ RepositoryAuthentication?: boolean; /** - * Password used in basic authentication. Required when RepositoryAuthentication is true. + * Deprecated: Use SourceID instead. Password used in basic authentication. */ RepositoryPassword?: string; /** @@ -7870,15 +7870,20 @@ export type EdgestacksEdgeStackFromGitRepositoryPayload = { */ RepositoryReferenceName?: string; /** - * URL of a Git repository hosting the Stack file + * Deprecated: Use SourceID instead. URL of a Git repository hosting the Stack file. */ - RepositoryURL: string; + RepositoryURL?: string; /** - * Username used in basic authentication. Required when RepositoryAuthentication is true. + * Deprecated: Use SourceID instead. Username used in basic authentication. */ RepositoryUsername?: string; /** - * TLSSkipVerify skips SSL verification when cloning the Git repository + * SourceID references an existing Source for git credentials/URL. + * When set, the inline URL and authentication fields are ignored. + */ + SourceID?: number; + /** + * Deprecated: Use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository. */ TLSSkipVerify?: boolean; /** diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index 79f4a185c3..b4299382cb 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -2922,8 +2922,9 @@ export const zEdgestacksEdgeStackFromGitRepositoryPayload = z.object({ RepositoryAuthentication: z.boolean().optional(), RepositoryPassword: z.string().optional(), RepositoryReferenceName: z.string().optional(), - RepositoryURL: z.string(), + RepositoryURL: z.string().optional(), RepositoryUsername: z.string().optional(), + SourceID: z.int().optional(), TLSSkipVerify: z.boolean().optional(), UseManifestNamespaces: z.boolean().optional(), }); diff --git a/app/react/portainer/gitops/sources/GitSourceSelector.tsx b/app/react/portainer/gitops/sources/GitSourceSelector.tsx index 4e87da9f25..d617451ce6 100644 --- a/app/react/portainer/gitops/sources/GitSourceSelector.tsx +++ b/app/react/portainer/gitops/sources/GitSourceSelector.tsx @@ -8,13 +8,16 @@ export function GitSourceSelector({ value, onChange, error, + readOnly = false, }: { value?: Source['id']; - onChange(source?: Source | null): void; + onChange?(source?: Source | null): void; error?: string; + readOnly?: boolean; }) { const sourcesQuery = useSources({ type: 'git' }); const sources = sourcesQuery.data?.data ?? []; + const selectedSource = sources.find((s) => s.id === value); return (
@@ -22,7 +25,7 @@ export function GitSourceSelector({