feat(edge/stacks): use source ID for edge stack git creation [BE-13044] (#2926)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chaim Lev-Ari
2026-06-16 17:33:19 +03:00
committed by GitHub
parent 32c6bedb98
commit ee8e73d7f9
12 changed files with 193 additions and 48 deletions
+55
View File
@@ -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
}
+70
View File
@@ -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)
}
@@ -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)
@@ -128,7 +128,9 @@ export function useValidation({
: 'manifest';
return buildGitValidationSchema(
!!customTemplate,
deploymentMethod
deploymentMethod,
false,
true
);
},
}) as SchemaOf<GitFormModel>,
@@ -140,6 +140,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
isSourceSelectionVisible
/>
{isBE && (
@@ -111,6 +111,7 @@ export function KubeManifestForm({
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
isSourceSelectionVisible
/>
)}
</>
@@ -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',
@@ -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 */
@@ -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,
@@ -7858,11 +7858,11 @@ export type EdgestacksEdgeStackFromGitRepositoryPayload = {
*/
Registries?: Array<number>;
/**
* 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;
/**
@@ -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(),
});
@@ -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 (
<div className="form-group">
@@ -22,7 +25,7 @@ export function GitSourceSelector({
<FormControl label="Source" inputId="source-selector" errors={error}>
<Select
placeholder="Select a source"
value={sources.find((s) => s.id === value) ?? null}
value={selectedSource ?? null}
options={sources}
getOptionLabel={(s) => s.name}
getOptionValue={(s) => String(s.id)}
@@ -32,6 +35,7 @@ export function GitSourceSelector({
noOptionsMessage={() => 'No git sources available'}
inputId="source-selector"
data-cy="source-selector"
isDisabled={readOnly}
/>
</FormControl>
</div>