mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
feat(helm): reuse existing git sources in Kubernetes Helm-from-git install [BE-13046] (#2900)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
package stacks
|
||||
package sources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,9 +8,16 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
// validateSourceForStack checks that the given Source exists and is a git Source, and returns it.
|
||||
// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need.
|
||||
// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types.
|
||||
type gitSourceStore interface {
|
||||
Source() dataservices.SourceService
|
||||
IsErrObjectNotFound(err error) bool
|
||||
}
|
||||
|
||||
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
|
||||
// TODO(BE-12905): enforce per-user access policies once Source ownership is introduced.
|
||||
func validateSourceForStack(tx dataservices.DataStoreTx, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
|
||||
func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
|
||||
src, err := tx.Source().Read(sourceID)
|
||||
if err != nil {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
@@ -23,5 +30,9 @@ func validateSourceForStack(tx dataservices.DataStoreTx, sourceID portainer.Sour
|
||||
return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil)
|
||||
}
|
||||
|
||||
if src.Git == nil {
|
||||
return nil, httperror.BadRequest("Source has no git configuration", nil)
|
||||
}
|
||||
|
||||
return src, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package sources
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateSourceForStack_ValidGitSource_ReturnsNil(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"},
|
||||
}
|
||||
require.NoError(t, store.Source().Create(src))
|
||||
|
||||
_, httpErr := ValidateGitSourceAccess(store, src.ID)
|
||||
assert.Nil(t, httpErr)
|
||||
}
|
||||
|
||||
func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
_, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999))
|
||||
require.NotNil(t, httpErr)
|
||||
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
|
||||
}
|
||||
|
||||
func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
src := &portainer.Source{
|
||||
Type: portainer.SourceType(99), // not a git source
|
||||
}
|
||||
require.NoError(t, store.Source().Create(src))
|
||||
|
||||
_, httpErr := ValidateGitSourceAccess(store, src.ID)
|
||||
require.NotNil(t, httpErr)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode)
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -19,19 +21,32 @@ type fileResponse struct {
|
||||
}
|
||||
|
||||
type repositoryFilePreviewPayload struct {
|
||||
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
|
||||
Reference string `json:"reference" example:"refs/heads/master"`
|
||||
Username string `json:"username" example:"myGitUsername"`
|
||||
Password string `json:"password" example:"myGitPassword"`
|
||||
// SourceID resolves URL and auth from the stored Source record.
|
||||
// When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored.
|
||||
SourceID portainer.SourceID `json:"sourceID" example:"1"`
|
||||
Reference string `json:"reference" example:"refs/heads/master"`
|
||||
// Path to file whose content will be read
|
||||
TargetFile string `json:"targetFile" example:"docker-compose.yml"`
|
||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
TLSSkipVerify bool `example:"false"`
|
||||
|
||||
// URL of a Git repository to preview.
|
||||
// Deprecated: use SourceID instead
|
||||
Repository string `json:"repository" example:"https://github.com/openfaas/faas"`
|
||||
// Username for git authentication.
|
||||
// Deprecated: use SourceID instead
|
||||
Username string `json:"username" example:"myGitUsername"`
|
||||
// Password for git authentication.
|
||||
// Deprecated: use SourceID instead
|
||||
Password string `json:"password" example:"myGitPassword"`
|
||||
// TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
// Deprecated: use SourceID instead
|
||||
TLSSkipVerify bool `json:"tlsSkipVerify" example:"false"`
|
||||
}
|
||||
|
||||
func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error {
|
||||
if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) {
|
||||
return errors.New("invalid repository URL. Must correspond to a valid URL format")
|
||||
if payload.SourceID == 0 {
|
||||
if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) {
|
||||
return errors.New("invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload.Reference) == 0 {
|
||||
@@ -56,6 +71,7 @@ func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error {
|
||||
// @param body body repositoryFilePreviewPayload true "Template details"
|
||||
// @success 200 {object} fileResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Source not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /gitops/repo/file/preview [post]
|
||||
func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
@@ -65,6 +81,25 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
repoURL := payload.Repository
|
||||
username := payload.Username
|
||||
password := payload.Password
|
||||
tlsSkipVerify := payload.TLSSkipVerify
|
||||
|
||||
if payload.SourceID != 0 {
|
||||
src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, payload.SourceID)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
repoURL = src.Git.URL
|
||||
if src.Git.Authentication != nil {
|
||||
username = src.Git.Authentication.Username
|
||||
password = src.Git.Authentication.Password
|
||||
}
|
||||
tlsSkipVerify = src.Git.TLSSkipVerify
|
||||
}
|
||||
|
||||
projectPath, err := handler.fileService.GetTemporaryPath()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to create temporary folder", err)
|
||||
@@ -73,11 +108,11 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
|
||||
err = handler.gitService.CloneRepository(
|
||||
context.TODO(),
|
||||
projectPath,
|
||||
payload.Repository,
|
||||
repoURL,
|
||||
payload.Reference,
|
||||
payload.Username,
|
||||
payload.Password,
|
||||
payload.TLSSkipVerify,
|
||||
username,
|
||||
password,
|
||||
tlsSkipVerify,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, gittypes.ErrAuthenticationFailure) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackbuilders"
|
||||
@@ -279,7 +280,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
}
|
||||
|
||||
if payload.SourceID != 0 {
|
||||
if _, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID); httpErr != nil {
|
||||
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComposeGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &composeStackFromGitRepositoryPayload{
|
||||
Name: "mystack",
|
||||
SourceID: portainer.SourceID(1),
|
||||
// RepositoryURL intentionally omitted
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestComposeGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &composeStackFromGitRepositoryPayload{
|
||||
Name: "mystack",
|
||||
// SourceID and RepositoryURL both omitted
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/portainer/portainer/api/stacks/stackbuilders"
|
||||
@@ -37,25 +38,34 @@ func createStackPayloadFromK8sFileContentPayload(name, namespace, fileContent st
|
||||
}
|
||||
|
||||
type kubernetesGitDeploymentPayload struct {
|
||||
StackName string
|
||||
ComposeFormat bool
|
||||
Namespace string
|
||||
RepositoryURL string
|
||||
RepositoryReferenceName string
|
||||
StackName string
|
||||
ComposeFormat bool
|
||||
Namespace string
|
||||
// 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
|
||||
// Deprecated: use SourceID instead. Reference name of a Git repository hosting the Stack file.
|
||||
RepositoryReferenceName string
|
||||
// Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
ManifestFile string
|
||||
AdditionalFiles []string
|
||||
AutoUpdate *portainer.AutoUpdateSettings
|
||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
// Deprecated: use SourceID instead. Username used in basic authentication.
|
||||
RepositoryUsername string
|
||||
// Deprecated: use SourceID instead. Password used in basic authentication.
|
||||
RepositoryPassword string
|
||||
ManifestFile string
|
||||
AdditionalFiles []string
|
||||
AutoUpdate *portainer.AutoUpdateSettings
|
||||
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
TLSSkipVerify bool `example:"false"`
|
||||
}
|
||||
|
||||
func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, repoSkipSSLVerify bool) stackbuilders.StackPayload {
|
||||
func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload {
|
||||
return stackbuilders.StackPayload{
|
||||
StackName: name,
|
||||
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
|
||||
SourceID: sourceID,
|
||||
URL: repoUrl,
|
||||
ReferenceName: repoReference,
|
||||
Authentication: repoAuthentication,
|
||||
@@ -94,12 +104,13 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
|
||||
}
|
||||
|
||||
func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
if payload.SourceID == 0 {
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload.ManifestFile) == 0 {
|
||||
@@ -218,6 +229,12 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
}
|
||||
}
|
||||
|
||||
if payload.SourceID != 0 {
|
||||
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
}
|
||||
|
||||
stackPayload := createStackPayloadFromK8sGitPayload(payload.StackName,
|
||||
payload.RepositoryURL,
|
||||
payload.RepositoryReferenceName,
|
||||
@@ -230,6 +247,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
payload.AdditionalFiles,
|
||||
payload.AutoUpdate,
|
||||
payload.TLSSkipVerify,
|
||||
payload.SourceID,
|
||||
)
|
||||
|
||||
k8sStackBuilder := stackbuilders.CreateKubernetesStackGitBuilder(handler.DataStore,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_URLNotRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := kubernetesGitDeploymentPayload{
|
||||
SourceID: portainer.SourceID(1),
|
||||
ManifestFile: "manifest.yaml",
|
||||
}
|
||||
err := p.Validate(nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_AuthNotRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := kubernetesGitDeploymentPayload{
|
||||
SourceID: portainer.SourceID(1),
|
||||
RepositoryAuthentication: true,
|
||||
// Password intentionally omitted — should not fail when SourceID is set
|
||||
ManifestFile: "manifest.yaml",
|
||||
}
|
||||
err := p.Validate(nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestKubernetesGitDeploymentPayloadValidate_WithoutSourceID_URLRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := kubernetesGitDeploymentPayload{
|
||||
ManifestFile: "manifest.yaml",
|
||||
// SourceID and RepositoryURL both omitted
|
||||
}
|
||||
err := p.Validate(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCreateStackPayloadFromK8sGitPayload_WithSourceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := createStackPayloadFromK8sGitPayload(
|
||||
"k8s-stack",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
"default",
|
||||
"manifest.yaml",
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
portainer.SourceID(7),
|
||||
)
|
||||
|
||||
require.Equal(t, portainer.SourceID(7), p.SourceID)
|
||||
require.Equal(t, "manifest.yaml", p.ManifestFile)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/stackbuilders"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
@@ -218,7 +219,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
}
|
||||
|
||||
if payload.SourceID != 0 {
|
||||
if _, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID); httpErr != nil {
|
||||
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSwarmGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &swarmStackFromGitRepositoryPayload{
|
||||
Name: "myswarm",
|
||||
SwarmID: "swarm-abc",
|
||||
SourceID: portainer.SourceID(1),
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &swarmStackFromGitRepositoryPayload{
|
||||
Name: "myswarm",
|
||||
SwarmID: "swarm-abc",
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"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/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateSourceForStack_ValidGitSource_ReturnsNil(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"},
|
||||
}
|
||||
require.NoError(t, store.Source().Create(src))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
_, httpErr := validateSourceForStack(store, src.ID)
|
||||
assert.Nil(t, httpErr)
|
||||
}
|
||||
|
||||
func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
_, httpErr := validateSourceForStack(store, portainer.SourceID(999))
|
||||
require.NotNil(t, httpErr)
|
||||
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
|
||||
}
|
||||
|
||||
func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
src := &portainer.Source{
|
||||
Type: portainer.SourceType(99), // not a git source
|
||||
}
|
||||
require.NoError(t, store.Source().Create(src))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
_, httpErr := validateSourceForStack(store, src.ID)
|
||||
require.NotNil(t, httpErr)
|
||||
assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode)
|
||||
}
|
||||
|
||||
func TestComposeGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &composeStackFromGitRepositoryPayload{
|
||||
Name: "mystack",
|
||||
SourceID: portainer.SourceID(1),
|
||||
// RepositoryURL intentionally omitted
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestComposeGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &composeStackFromGitRepositoryPayload{
|
||||
Name: "mystack",
|
||||
// SourceID and RepositoryURL both omitted
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &swarmStackFromGitRepositoryPayload{
|
||||
Name: "myswarm",
|
||||
SwarmID: "swarm-abc",
|
||||
SourceID: portainer.SourceID(1),
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSwarmGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := &swarmStackFromGitRepositoryPayload{
|
||||
Name: "myswarm",
|
||||
SwarmID: "swarm-abc",
|
||||
}
|
||||
|
||||
err := payload.Validate(nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
@@ -193,7 +194,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
|
||||
if payload.SourceID != 0 {
|
||||
src, httpErr := validateSourceForStack(handler.DataStore, payload.SourceID)
|
||||
src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export type KubernetesGitRepositoryPayload = {
|
||||
composeFormat: boolean;
|
||||
namespace: string;
|
||||
|
||||
/** When set, URL and auth are resolved from the stored Source record */
|
||||
sourceId?: number;
|
||||
|
||||
/** URL of a Git repository hosting the Stack file */
|
||||
repositoryUrl: string;
|
||||
/** Reference name of a Git repository hosting the Stack file */
|
||||
|
||||
@@ -293,6 +293,7 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
|
||||
return createKubernetesStackFromGit({
|
||||
stackName: payload.name,
|
||||
|
||||
sourceId: payload.git.SourceId,
|
||||
repositoryUrl: payload.git.RepositoryURL,
|
||||
repositoryReferenceName: payload.git.RepositoryReferenceName,
|
||||
manifestFile: payload.git.ComposeFilePathInRepository,
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('Git validation', () => {
|
||||
const schema = getGitValidationSchema();
|
||||
|
||||
const validData: GitFormValues = {
|
||||
SourceId: 1,
|
||||
RepositoryURL: 'https://github.com/user/repo',
|
||||
RepositoryReferenceName: 'refs/heads/main',
|
||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
@@ -18,7 +19,6 @@ describe('Git validation', () => {
|
||||
RepositoryAuthorizationType: undefined,
|
||||
SupportRelativePath: false,
|
||||
FilesystemPath: '',
|
||||
SourceId: 1,
|
||||
};
|
||||
|
||||
await expect(schema.validate(validData)).resolves.toBeDefined();
|
||||
|
||||
@@ -4458,14 +4458,34 @@ export type StacksKubernetesGitDeploymentPayload = {
|
||||
ComposeFormat?: boolean;
|
||||
ManifestFile?: string;
|
||||
Namespace?: string;
|
||||
/**
|
||||
* Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
|
||||
*/
|
||||
RepositoryAuthentication?: boolean;
|
||||
/**
|
||||
* Deprecated: use SourceID instead. Password used in basic authentication.
|
||||
*/
|
||||
RepositoryPassword?: string;
|
||||
/**
|
||||
* Deprecated: use SourceID instead. Reference name of a Git repository hosting the Stack file.
|
||||
*/
|
||||
RepositoryReferenceName?: string;
|
||||
/**
|
||||
* Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
|
||||
*/
|
||||
RepositoryURL?: string;
|
||||
/**
|
||||
* Deprecated: use SourceID instead. Username used in basic authentication.
|
||||
*/
|
||||
RepositoryUsername?: string;
|
||||
/**
|
||||
* SourceID references an existing Source for git credentials/URL.
|
||||
* When set, the inline URL and authentication fields are ignored.
|
||||
*/
|
||||
SourceID?: number;
|
||||
StackName?: string;
|
||||
/**
|
||||
* TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
* Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
*/
|
||||
TLSSkipVerify?: boolean;
|
||||
};
|
||||
@@ -7449,16 +7469,34 @@ export type HelmInstallChartPayload = {
|
||||
|
||||
export type GitopsRepositoryFilePreviewPayload = {
|
||||
/**
|
||||
* TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
* Password for git authentication.
|
||||
* Deprecated: use SourceID instead
|
||||
*/
|
||||
TLSSkipVerify?: boolean;
|
||||
password?: string;
|
||||
reference?: string;
|
||||
repository: string;
|
||||
/**
|
||||
* URL of a Git repository to preview.
|
||||
* Deprecated: use SourceID instead
|
||||
*/
|
||||
repository?: string;
|
||||
/**
|
||||
* SourceID resolves URL and auth from the stored Source record.
|
||||
* When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored.
|
||||
*/
|
||||
sourceID?: number;
|
||||
/**
|
||||
* Path to file whose content will be read
|
||||
*/
|
||||
targetFile?: string;
|
||||
/**
|
||||
* TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
* Deprecated: use SourceID instead
|
||||
*/
|
||||
tlsSkipVerify?: boolean;
|
||||
/**
|
||||
* Username for git authentication.
|
||||
* Deprecated: use SourceID instead
|
||||
*/
|
||||
username?: string;
|
||||
};
|
||||
|
||||
@@ -11217,6 +11255,10 @@ export type GitOperationRepoFilePreviewErrors = {
|
||||
* Invalid request
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Source not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Server error
|
||||
*/
|
||||
|
||||
@@ -1172,6 +1172,7 @@ export const zStacksKubernetesGitDeploymentPayload = z.object({
|
||||
RepositoryReferenceName: z.string().optional(),
|
||||
RepositoryURL: z.string().optional(),
|
||||
RepositoryUsername: z.string().optional(),
|
||||
SourceID: z.int().optional(),
|
||||
StackName: z.string().optional(),
|
||||
TLSSkipVerify: z.boolean().optional(),
|
||||
});
|
||||
@@ -2711,11 +2712,12 @@ export const zHelmInstallChartPayload = z.object({
|
||||
});
|
||||
|
||||
export const zGitopsRepositoryFilePreviewPayload = z.object({
|
||||
TLSSkipVerify: z.boolean().optional(),
|
||||
password: z.string().optional(),
|
||||
reference: z.string().optional(),
|
||||
repository: z.string(),
|
||||
repository: z.string().optional(),
|
||||
sourceID: z.int().optional(),
|
||||
targetFile: z.string().optional(),
|
||||
tlsSkipVerify: z.boolean().optional(),
|
||||
username: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ interface Params {
|
||||
createdFromCustomTemplateId?: number;
|
||||
fromEdgeStack?: boolean;
|
||||
stackId?: number;
|
||||
/** When set, the refs check will use credentials from the stored Source record */
|
||||
sourceId?: number;
|
||||
enabled?: boolean;
|
||||
onSettled?(isValid?: boolean): void;
|
||||
// run after onSettled, useful for clearing local flags like force
|
||||
@@ -32,6 +34,7 @@ export function useGitRepoValidity({
|
||||
fromEdgeStack,
|
||||
createdFromCustomTemplateId,
|
||||
stackId,
|
||||
sourceId,
|
||||
enabled,
|
||||
onSettled,
|
||||
onAfterSettle,
|
||||
@@ -45,9 +48,10 @@ export function useGitRepoValidity({
|
||||
stackId,
|
||||
force,
|
||||
fromEdgeStack,
|
||||
sourceId,
|
||||
},
|
||||
{
|
||||
enabled: !!url && enabled,
|
||||
enabled: (!!url || !!sourceId) && enabled,
|
||||
select: () => true,
|
||||
suppressError: true,
|
||||
onSettled(isValid) {
|
||||
@@ -61,7 +65,7 @@ export function useGitRepoValidity({
|
||||
}
|
||||
);
|
||||
|
||||
const hasCreds = !!(creds?.username && creds?.password);
|
||||
const hasCreds = !!(creds?.username && creds?.password) || !!sourceId;
|
||||
|
||||
const errorMessage = getGitValidityError(query.error, hasCreds);
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface GitFilePreviewParams {
|
||||
password?: string;
|
||||
authorizationType?: AuthTypeOption;
|
||||
tlsSkipVerify?: boolean;
|
||||
/** When set, resolves URL and auth from the stored Source record */
|
||||
sourceId?: number;
|
||||
}
|
||||
|
||||
async function getFilePreview(params: GitFilePreviewParams): Promise<string> {
|
||||
@@ -35,7 +37,10 @@ export function useGitFilePreview<TData = string>(
|
||||
return useQuery({
|
||||
queryKey: ['gitops', 'file-preview', omitPassword(params)],
|
||||
queryFn: () => getFilePreview(params),
|
||||
enabled: enabled && !!params.repository && !!params.targetFile,
|
||||
enabled:
|
||||
enabled &&
|
||||
(!!params.repository || !!params.sourceId) &&
|
||||
!!params.targetFile,
|
||||
select,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user