mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user