Compare commits
21 Commits
develop
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8332232840 | ||
|
|
816a6f9bef | ||
|
|
e86ea22900 | ||
|
|
12b2acbc00 | ||
|
|
4a8b42928e | ||
|
|
2e828b39da | ||
|
|
49c6521c23 | ||
|
|
debf1a742b | ||
|
|
5d3708ec3e | ||
|
|
9320fd4c50 | ||
|
|
974682bd98 | ||
|
|
631f1deb2e | ||
|
|
4169b045fb | ||
|
|
0a2a786aa3 | ||
|
|
808f87206e | ||
|
|
ed6fa82904 | ||
|
|
9fc301110b | ||
|
|
69101ac89a | ||
|
|
69d33dd432 | ||
|
|
389cbf748c | ||
|
|
d01b31f707 |
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
// Service implements the CLIService interface
|
||||
@@ -35,16 +35,9 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
|
||||
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
||||
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
|
||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
||||
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
||||
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
|
||||
HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(),
|
||||
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
|
||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
|
||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
|
||||
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
|
||||
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
|
||||
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
|
||||
@@ -70,8 +63,37 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
kingpin.Version(version)
|
||||
|
||||
var hasSSLFlag, hasSSLCertFlag, hasSSLKeyFlag bool
|
||||
sslFlag := kingpin.Flag(
|
||||
"ssl",
|
||||
"Secure Portainer instance using SSL (deprecated)",
|
||||
).Default(defaultSSL).IsSetByUser(&hasSSLFlag)
|
||||
ssl := sslFlag.Bool()
|
||||
sslCertFlag := kingpin.Flag(
|
||||
"sslcert",
|
||||
"Path to the SSL certificate used to secure the Portainer instance",
|
||||
).IsSetByUser(&hasSSLCertFlag)
|
||||
sslCert := sslCertFlag.String()
|
||||
sslKeyFlag := kingpin.Flag(
|
||||
"sslkey",
|
||||
"Path to the SSL key used to secure the Portainer instance",
|
||||
).IsSetByUser(&hasSSLKeyFlag)
|
||||
sslKey := sslKeyFlag.String()
|
||||
|
||||
flags := CLIFlags()
|
||||
|
||||
var hasTLSFlag, hasTLSCertFlag, hasTLSKeyFlag bool
|
||||
tlsFlag := kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).IsSetByUser(&hasTLSFlag)
|
||||
flags.TLS = tlsFlag.Bool()
|
||||
tlsCertFlag := kingpin.Flag(
|
||||
"tlscert",
|
||||
"Path to the TLS certificate file",
|
||||
).Default(defaultTLSCertPath).IsSetByUser(&hasTLSCertFlag)
|
||||
flags.TLSCert = tlsCertFlag.String()
|
||||
tlsKeyFlag := kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).IsSetByUser(&hasTLSKeyFlag)
|
||||
flags.TLSKey = tlsKeyFlag.String()
|
||||
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
|
||||
|
||||
kingpin.Parse()
|
||||
|
||||
if !filepath.IsAbs(*flags.Assets) {
|
||||
@@ -83,6 +105,41 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
|
||||
}
|
||||
|
||||
// If the user didn't provide a tls flag remove the defaults to match previous behaviour
|
||||
if !hasTLSFlag {
|
||||
if !hasTLSCertFlag {
|
||||
*flags.TLSCert = ""
|
||||
}
|
||||
|
||||
if !hasTLSKeyFlag {
|
||||
*flags.TLSKey = ""
|
||||
}
|
||||
}
|
||||
|
||||
if hasSSLFlag {
|
||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslFlag.Model().Name, tlsFlag.Model().Name)
|
||||
|
||||
if !hasTLSFlag {
|
||||
flags.TLS = ssl
|
||||
}
|
||||
}
|
||||
|
||||
if hasSSLCertFlag {
|
||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslCertFlag.Model().Name, tlsCertFlag.Model().Name)
|
||||
|
||||
if !hasTLSCertFlag {
|
||||
flags.TLSCert = sslCert
|
||||
}
|
||||
}
|
||||
|
||||
if hasSSLKeyFlag {
|
||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslKeyFlag.Model().Name, tlsKeyFlag.Model().Name)
|
||||
|
||||
if !hasTLSKeyFlag {
|
||||
flags.TLSKey = sslKey
|
||||
}
|
||||
}
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
@@ -109,10 +166,6 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
|
||||
if *flags.NoAnalytics {
|
||||
log.Warn().Msg("the --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect")
|
||||
}
|
||||
|
||||
if *flags.SSL {
|
||||
log.Warn().Msg("SSL is enabled by default and there is no need for the --ssl flag, it has been kept to allow migration of instances running a previous version of Portainer with this flag enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func validateEndpointURL(endpointURL string) error {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
zerolog "github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -22,3 +25,185 @@ func TestOptionParser(t *testing.T) {
|
||||
require.False(t, *opts.HTTPDisabled)
|
||||
require.True(t, *opts.EnableEdgeComputeFeatures)
|
||||
}
|
||||
|
||||
func TestParseTLSFlags(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedTLSFlag bool
|
||||
expectedTLSCertFlag string
|
||||
expectedTLSKeyFlag string
|
||||
expectedLogMessages []string
|
||||
}{
|
||||
{
|
||||
name: "no flags",
|
||||
expectedTLSFlag: false,
|
||||
expectedTLSCertFlag: "",
|
||||
expectedTLSKeyFlag: "",
|
||||
},
|
||||
{
|
||||
name: "only ssl flag",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "",
|
||||
expectedTLSKeyFlag: "",
|
||||
},
|
||||
{
|
||||
name: "only tls flag",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: defaultTLSCertPath,
|
||||
expectedTLSKeyFlag: defaultTLSKeyPath,
|
||||
},
|
||||
{
|
||||
name: "partial ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
"--sslcert=ssl-cert-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "ssl-cert-flag-value",
|
||||
expectedTLSKeyFlag: "",
|
||||
},
|
||||
{
|
||||
name: "partial tls flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: defaultTLSKeyPath,
|
||||
},
|
||||
{
|
||||
name: "partial tls and ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||
},
|
||||
{
|
||||
name: "partial tls and ssl flags 2",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||
},
|
||||
{
|
||||
name: "ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
"--sslcert=ssl-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "ssl-cert-flag-value",
|
||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||
expectedLogMessages: []string{
|
||||
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
||||
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
||||
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tls flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--tlskey=tls-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "tls-key-flag-value",
|
||||
},
|
||||
{
|
||||
name: "tls and ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--tlskey=tls-key-flag-value",
|
||||
"--ssl",
|
||||
"--sslcert=ssl-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "tls-key-flag-value",
|
||||
expectedLogMessages: []string{
|
||||
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
||||
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
||||
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var logOutput strings.Builder
|
||||
setupLogOutput(t, &logOutput)
|
||||
|
||||
if tc.args == nil {
|
||||
tc.args = []string{"portainer"}
|
||||
}
|
||||
setOsArgs(t, tc.args)
|
||||
|
||||
s := Service{}
|
||||
flags, err := s.ParseFlags("test-version")
|
||||
if err != nil {
|
||||
t.Fatalf("error parsing flags: %v", err)
|
||||
}
|
||||
|
||||
if flags.TLS == nil {
|
||||
t.Fatal("TLS flag was nil")
|
||||
}
|
||||
|
||||
require.Equal(t, tc.expectedTLSFlag, *flags.TLS, "tlsverify flag didn't match")
|
||||
require.Equal(t, tc.expectedTLSCertFlag, *flags.TLSCert, "tlscert flag didn't match")
|
||||
require.Equal(t, tc.expectedTLSKeyFlag, *flags.TLSKey, "tlskey flag didn't match")
|
||||
|
||||
for _, expectedLogMessage := range tc.expectedLogMessages {
|
||||
require.Contains(t, logOutput.String(), expectedLogMessage, "Log didn't contain expected message")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setOsArgs(t *testing.T, args []string) {
|
||||
t.Helper()
|
||||
previousArgs := os.Args
|
||||
os.Args = args
|
||||
t.Cleanup(func() {
|
||||
os.Args = previousArgs
|
||||
})
|
||||
}
|
||||
|
||||
func setupLogOutput(t *testing.T, w io.Writer) {
|
||||
t.Helper()
|
||||
|
||||
oldLogger := zerolog.Logger
|
||||
zerolog.Logger = zerolog.Output(w)
|
||||
t.Cleanup(func() {
|
||||
zerolog.Logger = oldLogger
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
)
|
||||
|
||||
type pairList []portainer.Pair
|
||||
|
||||
@@ -408,7 +408,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
edgeStacksService := edgestacks.NewService(dataStore)
|
||||
|
||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
|
||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.TLSCert, *flags.TLSKey, fileService, dataStore, shutdownTrigger)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
@@ -15,7 +16,6 @@ import (
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
@@ -248,7 +248,10 @@ func aesEncryptGCMFIPS(input io.Reader, output io.Writer, passphrase []byte) err
|
||||
return err
|
||||
}
|
||||
|
||||
key := pbkdf2.Key(passphrase, salt, pbkdf2Iterations, 32, sha256.New)
|
||||
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deriving key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@@ -315,7 +318,10 @@ func aesDecryptGCMFIPS(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := pbkdf2.Key(passphrase, salt, pbkdf2Iterations, 32, sha256.New)
|
||||
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error deriving key: %w", err)
|
||||
}
|
||||
|
||||
// Initialize AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/fips140"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
)
|
||||
|
||||
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
|
||||
func CreateTLSConfiguration(insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
||||
// TODO: use fips.FIPSMode() instead
|
||||
return createTLSConfiguration(fips140.Enabled(), insecureSkipVerify)
|
||||
return createTLSConfiguration(fips.FIPSMode(), insecureSkipVerify)
|
||||
}
|
||||
|
||||
func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
||||
@@ -58,8 +57,7 @@ func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Conf
|
||||
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from memory.
|
||||
func CreateTLSConfigurationFromBytes(useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
||||
// TODO: use fips.FIPSMode() instead
|
||||
return createTLSConfigurationFromBytes(fips140.Enabled(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification)
|
||||
return createTLSConfigurationFromBytes(fips.FIPSMode(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification)
|
||||
}
|
||||
|
||||
func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
||||
@@ -90,8 +88,7 @@ func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key
|
||||
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from disk.
|
||||
func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
// TODO: use fips.FIPSMode() instead
|
||||
return createTLSConfigurationFromDisk(fips140.Enabled(), config)
|
||||
return createTLSConfigurationFromDisk(fips.FIPSMode(), config)
|
||||
}
|
||||
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
|
||||
@@ -91,9 +91,9 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
|
||||
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStack)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/dataservices/edgestack"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -102,3 +103,38 @@ func TestUpdateRelation(t *testing.T) {
|
||||
require.Equal(t, 0, edgeStacks[edgeStackID1].NumDeployments)
|
||||
require.Equal(t, 0, edgeStacks[edgeStackID2].NumDeployments)
|
||||
}
|
||||
|
||||
func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
service, err := NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
edgeStackService, err := edgestack.NewService(conn, func(t portainer.Transaction, esi portainer.EdgeStackID) {})
|
||||
require.NoError(t, err)
|
||||
|
||||
service.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFuncTx)
|
||||
require.NoError(t, edgeStackService.Create(1, &portainer.EdgeStack{}))
|
||||
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}}))
|
||||
require.NoError(t, service.AddEndpointRelationsForEdgeStack([]portainer.EndpointID{1}, &portainer.EdgeStack{ID: 1}))
|
||||
}
|
||||
|
||||
func TestEndpointRelations(t *testing.T) {
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
service, err := NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1}))
|
||||
rels, err := service.EndpointRelations()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(rels))
|
||||
}
|
||||
|
||||
@@ -76,14 +76,14 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
rel, err := service.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel.EdgeStacks[edgeStackID] = true
|
||||
rel.EdgeStacks[edgeStack.ID] = true
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||
@@ -97,8 +97,12 @@ func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portaine
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments += len(endpointIDs)
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStack.ID, func(es *portainer.EdgeStack) {
|
||||
es.NumDeployments += len(endpointIDs)
|
||||
|
||||
// sync changes in `edgeStack` in case it is re-persisted after `AddEndpointRelationsForEdgeStack` call
|
||||
// to avoid overriding with the previous values
|
||||
edgeStack.NumDeployments = es.NumDeployments
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ type (
|
||||
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
||||
Create(endpointRelation *portainer.EndpointRelation) error
|
||||
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
||||
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error
|
||||
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
||||
BucketName() string
|
||||
|
||||
@@ -11,8 +11,10 @@ func (m *Migrator) migrateEdgeGroupEndpointsToRoars_2_33_0() error {
|
||||
}
|
||||
|
||||
for _, eg := range egs {
|
||||
eg.EndpointIDs = roar.FromSlice(eg.Endpoints)
|
||||
eg.Endpoints = nil
|
||||
if eg.EndpointIDs.Len() == 0 {
|
||||
eg.EndpointIDs = roar.FromSlice(eg.Endpoints)
|
||||
eg.Endpoints = nil
|
||||
}
|
||||
|
||||
if err := m.edgeGroupService.Update(eg.ID, &eg); err != nil {
|
||||
return err
|
||||
|
||||
55
api/datastore/migrator/migrate_2_33_test.go
Normal file
55
api/datastore/migrator/migrate_2_33_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/dataservices/edgegroup"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
edgeGroupService, err := edgegroup.NewService(conn)
|
||||
require.NoError(t, err)
|
||||
|
||||
edgeGroup := &portainer.EdgeGroup{
|
||||
ID: 1,
|
||||
Name: "test-edge-group",
|
||||
Endpoints: []portainer.EndpointID{1, 2, 3},
|
||||
}
|
||||
|
||||
err = conn.CreateObjectWithId(edgegroup.BucketName, int(edgeGroup.ID), edgeGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := NewMigrator(&MigratorParameters{EdgeGroupService: edgeGroupService})
|
||||
|
||||
// Run migration once
|
||||
|
||||
err = m.migrateEdgeGroupEndpointsToRoars_2_33_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
migratedEdgeGroup, err := edgeGroupService.Read(edgeGroup.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, migratedEdgeGroup.Endpoints, 0)
|
||||
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
|
||||
|
||||
// Run migration again to ensure the results didn't change
|
||||
|
||||
err = m.migrateEdgeGroupEndpointsToRoars_2_33_0()
|
||||
require.NoError(t, err)
|
||||
|
||||
migratedEdgeGroup, err = edgeGroupService.Read(edgeGroup.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, migratedEdgeGroup.Endpoints, 0)
|
||||
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
|
||||
}
|
||||
@@ -256,10 +256,7 @@ func (m *Migrator) initMigrations() {
|
||||
|
||||
m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0)
|
||||
|
||||
m.addMigrations("2.33.0-rc1", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
|
||||
|
||||
//m.addMigrations("2.33.0", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
|
||||
// when we release 2.33.0 it will also run the rc-1 migration function
|
||||
m.addMigrations("2.33.1", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
|
||||
|
||||
// Add new migrations above...
|
||||
// One function per migration, each versions migration funcs in the same file.
|
||||
|
||||
@@ -2,6 +2,7 @@ package postinit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -83,17 +84,27 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
|
||||
|
||||
// try to create a post init migration pending action. If it already exists, do nothing
|
||||
// this function exists for readability, not reusability
|
||||
// TODO: This should be moved into pending actions as part of the pending action migration
|
||||
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
|
||||
// If there are no pending actions for the given endpoint, create one
|
||||
err := postInitMigrator.dataStore.PendingActions().Create(&portainer.PendingAction{
|
||||
action := portainer.PendingAction{
|
||||
EndpointID: environmentID,
|
||||
Action: actions.PostInitMigrateEnvironment,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environmentID)
|
||||
}
|
||||
return nil
|
||||
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
for _, dba := range pendingActions {
|
||||
if dba.EndpointID == action.EndpointID && dba.Action == action.Action {
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return postInitMigrator.dataStore.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
// MigrateEnvironment runs migrations on a single environment
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -73,3 +75,96 @@ func TestMigrateGPUs(t *testing.T) {
|
||||
require.False(t, migratedEndpoint.PostInitMigrations.MigrateGPUs)
|
||||
require.True(t, migratedEndpoint.EnableGPUManagement)
|
||||
}
|
||||
|
||||
func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existingPendingActions []*portainer.PendingAction
|
||||
expectedPendingActions int
|
||||
expectedAction string
|
||||
}{
|
||||
{
|
||||
name: "when existing non-matching action exists, should add migration action",
|
||||
existingPendingActions: []*portainer.PendingAction{
|
||||
{
|
||||
EndpointID: 7,
|
||||
Action: "some-other-action",
|
||||
},
|
||||
},
|
||||
expectedPendingActions: 2,
|
||||
expectedAction: actions.PostInitMigrateEnvironment,
|
||||
},
|
||||
{
|
||||
name: "when matching action exists, should not add duplicate",
|
||||
existingPendingActions: []*portainer.PendingAction{
|
||||
{
|
||||
EndpointID: 7,
|
||||
Action: actions.PostInitMigrateEnvironment,
|
||||
},
|
||||
},
|
||||
expectedPendingActions: 1,
|
||||
expectedAction: actions.PostInitMigrateEnvironment,
|
||||
},
|
||||
{
|
||||
name: "when no actions exist, should add migration action",
|
||||
existingPendingActions: []*portainer.PendingAction{},
|
||||
expectedPendingActions: 1,
|
||||
expectedAction: actions.PostInitMigrateEnvironment,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// Create test endpoint
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: 7,
|
||||
UserTrusted: true,
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
Edge: portainer.EnvironmentEdgeSettings{
|
||||
AsyncMode: false,
|
||||
},
|
||||
EdgeID: "edgeID",
|
||||
}
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
is.NoError(err, "error creating endpoint")
|
||||
|
||||
// Create any existing pending actions
|
||||
for _, action := range tt.existingPendingActions {
|
||||
err = store.PendingActions().Create(action)
|
||||
is.NoError(err, "error creating pending action")
|
||||
}
|
||||
|
||||
migrator := NewPostInitMigrator(
|
||||
nil, // kubeFactory not needed for this test
|
||||
nil, // dockerFactory not needed for this test
|
||||
store,
|
||||
"", // assetsPath not needed for this test
|
||||
nil, // kubernetesDeployer not needed for this test
|
||||
)
|
||||
|
||||
err = migrator.PostInitMigrate()
|
||||
is.NoError(err, "PostInitMigrate should not return error")
|
||||
|
||||
// Verify the results
|
||||
pendingActions, err := store.PendingActions().ReadAll()
|
||||
is.NoError(err, "error reading pending actions")
|
||||
is.Len(pendingActions, tt.expectedPendingActions, "unexpected number of pending actions")
|
||||
|
||||
// If we expect any actions, verify at least one has the expected action type
|
||||
if tt.expectedPendingActions > 0 {
|
||||
hasExpectedAction := false
|
||||
for _, action := range pendingActions {
|
||||
if action.Action == tt.expectedAction {
|
||||
hasExpectedAction = true
|
||||
is.Equal(endpoint.ID, action.EndpointID, "action should reference correct endpoint")
|
||||
break
|
||||
}
|
||||
}
|
||||
is.True(hasExpectedAction, "should have found action of expected type")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.33.0-rc1",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.33.1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -944,7 +944,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.33.0-rc1\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.33.1\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHttpClient(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
// Valid TLS configuration
|
||||
endpoint := &portainer.Endpoint{}
|
||||
endpoint.TLSConfig = portainer.TLSConfiguration{TLS: true}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -234,6 +235,8 @@ func Test_isAzureUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
type args struct {
|
||||
options baseOption
|
||||
}
|
||||
@@ -308,6 +311,8 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_azureDownloader_latestCommitID(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := `{
|
||||
"count": 1,
|
||||
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
service := NewService(true)
|
||||
require.NotNil(t, service)
|
||||
require.True(t, service.httpsClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo
|
||||
|
||||
@@ -6,11 +6,14 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecutePingOperationFailure(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
host := "http://localhost:1"
|
||||
config := portainer.TLSConfiguration{
|
||||
TLS: true,
|
||||
|
||||
@@ -99,7 +99,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
||||
|
||||
groupsIds := stack.EdgeGroups
|
||||
if payload.EdgeGroups != nil {
|
||||
newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack.ID, payload.EdgeGroups, relatedEndpointIds, relationConfig)
|
||||
newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack, payload.EdgeGroups, relatedEndpointIds, relationConfig)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to handle edge groups change", err)
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) {
|
||||
func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) {
|
||||
newRelatedEnvironmentIDs, err := edge.EdgeStackRelatedEndpoints(newEdgeGroupsIDs, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
@@ -149,13 +149,13 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
|
||||
|
||||
if len(relatedEnvironmentsToRemove) > 0 {
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID); err != nil {
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStack.ID); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to remove edge stack relations from the database")
|
||||
}
|
||||
}
|
||||
|
||||
if len(relatedEnvironmentsToAdd) > 0 {
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID); err != nil {
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStack); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to add edge stack relations to the database")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,10 +372,16 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay
|
||||
edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID)
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
Name: payload.Name,
|
||||
URL: portainerHost,
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
Name: payload.Name,
|
||||
URL: portainerHost,
|
||||
Type: func() portainer.EndpointType {
|
||||
// an empty container engine means that the endpoint is a Kubernetes endpoint
|
||||
if payload.ContainerEngine == "" {
|
||||
return portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
return portainer.EdgeAgentOnDockerEnvironment
|
||||
}(),
|
||||
ContainerEngine: payload.ContainerEngine,
|
||||
GroupID: portainer.EndpointGroupID(payload.GroupID),
|
||||
Gpus: payload.Gpus,
|
||||
|
||||
172
api/http/handler/endpoints/endpoint_create_test.go
Normal file
172
api/http/handler/endpoints/endpoint_create_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// EE-only kubeconfig validation tests removed for CE
|
||||
|
||||
func TestSaveEndpointAndUpdateAuthorizations(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
endpointGroup := &portainer.EndpointGroup{
|
||||
ID: 1,
|
||||
Name: "test-endpoint-group",
|
||||
}
|
||||
|
||||
err := store.EndpointGroup().Create(endpointGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
h := &Handler{
|
||||
DataStore: store,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
endpointType portainer.EndpointType
|
||||
expectRelation bool
|
||||
}{
|
||||
{
|
||||
name: "create azure environment, expect no relation to be created",
|
||||
endpointType: portainer.AzureEnvironment,
|
||||
expectRelation: false,
|
||||
},
|
||||
{
|
||||
name: "create edge agent environment, expect relation to be created",
|
||||
endpointType: portainer.EdgeAgentOnDockerEnvironment,
|
||||
expectRelation: true,
|
||||
},
|
||||
{
|
||||
name: "create kubernetes environment, expect no relation to be created",
|
||||
endpointType: portainer.KubernetesLocalEnvironment,
|
||||
expectRelation: false,
|
||||
},
|
||||
{
|
||||
name: "create kubeconfig environment, expect no relation to be created",
|
||||
endpointType: portainer.AgentOnKubernetesEnvironment,
|
||||
expectRelation: false,
|
||||
},
|
||||
{
|
||||
name: "create agent docker environment, expect no relation to be created",
|
||||
endpointType: portainer.AgentOnDockerEnvironment,
|
||||
expectRelation: false,
|
||||
},
|
||||
{
|
||||
name: "create unsecured environment, expect no relation to be created",
|
||||
endpointType: portainer.DockerEnvironment,
|
||||
expectRelation: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(store.Endpoint().GetNextIdentifier()),
|
||||
Type: testCase.endpointType,
|
||||
GroupID: portainer.EndpointGroupID(endpointGroup.ID),
|
||||
}
|
||||
|
||||
err := h.saveEndpointAndUpdateAuthorizations(store, endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
relation, relationErr := store.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||
if testCase.expectRelation {
|
||||
require.NoError(t, relationErr)
|
||||
require.NotNil(t, relation)
|
||||
} else {
|
||||
require.Error(t, relationErr)
|
||||
require.True(t, store.IsErrObjectNotFound(relationErr))
|
||||
require.Nil(t, relation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEndpointFailure(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
h := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
h.DataStore = store
|
||||
|
||||
payload := &endpointCreatePayload{
|
||||
Name: "Test Endpoint",
|
||||
EndpointCreationType: agentEnvironment,
|
||||
TLS: true,
|
||||
TLSCertFile: []byte("invalid data"),
|
||||
TLSKeyFile: []byte("invalid data"),
|
||||
}
|
||||
|
||||
endpoint, httpErr := h.createEndpoint(store, payload)
|
||||
require.NotNil(t, httpErr)
|
||||
require.Equal(t, http.StatusInternalServerError, httpErr.StatusCode)
|
||||
require.Nil(t, endpoint)
|
||||
}
|
||||
|
||||
func TestCreateEdgeAgentEndpoint_ContainerEngineMapping(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
// required group for save flow
|
||||
endpointGroup := &portainer.EndpointGroup{ID: 1, Name: "test-group"}
|
||||
err := store.EndpointGroup().Create(endpointGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
h := &Handler{
|
||||
DataStore: store,
|
||||
ReverseTunnelService: chisel.NewService(store, nil, nil),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
engine string
|
||||
wantType portainer.EndpointType
|
||||
}{
|
||||
{
|
||||
name: "empty engine -> EdgeAgentOnKubernetesEnvironment",
|
||||
engine: "",
|
||||
wantType: portainer.EdgeAgentOnKubernetesEnvironment,
|
||||
},
|
||||
{
|
||||
name: "docker engine -> EdgeAgentOnDockerEnvironment",
|
||||
engine: portainer.ContainerEngineDocker,
|
||||
wantType: portainer.EdgeAgentOnDockerEnvironment,
|
||||
},
|
||||
{
|
||||
name: "podman engine -> EdgeAgentOnDockerEnvironment",
|
||||
engine: portainer.ContainerEnginePodman,
|
||||
wantType: portainer.EdgeAgentOnDockerEnvironment,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := &endpointCreatePayload{
|
||||
Name: "edge-endpoint",
|
||||
EndpointCreationType: edgeAgentEnvironment,
|
||||
ContainerEngine: tc.engine,
|
||||
GroupID: 1,
|
||||
URL: "https://portainer.example:9443",
|
||||
}
|
||||
|
||||
ep, httpErr := h.createEdgeAgentEndpoint(store, payload)
|
||||
require.Nil(t, httpErr)
|
||||
require.NotNil(t, ep)
|
||||
|
||||
assert.Equal(t, tc.wantType, ep.Type)
|
||||
assert.Equal(t, tc.engine, ep.ContainerEngine)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request
|
||||
continue
|
||||
}
|
||||
|
||||
endpoint.Status = portainer.EndpointStatusUp
|
||||
latestEndpointReference.Status = portainer.EndpointStatusUp
|
||||
if snapshotError != nil {
|
||||
log.Debug().
|
||||
Str("endpoint", endpoint.Name).
|
||||
@@ -57,7 +57,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request
|
||||
Err(snapshotError).
|
||||
Msg("background schedule error (environment snapshot), unable to create snapshot")
|
||||
|
||||
endpoint.Status = portainer.EndpointStatusDown
|
||||
latestEndpointReference.Status = portainer.EndpointStatusDown
|
||||
}
|
||||
|
||||
latestEndpointReference.Agent.Version = endpoint.Agent.Version
|
||||
|
||||
107
api/http/handler/endpoints/endpoint_snapshots_test.go
Normal file
107
api/http/handler/endpoints/endpoint_snapshots_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_endpointSnapshots(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
endpointID := portainer.EndpointID(123)
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: endpointID,
|
||||
Name: "mock",
|
||||
URL: "http://mock.example/",
|
||||
Status: portainer.EndpointStatusDown, // starts in down state
|
||||
}
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
|
||||
require.NoError(t, err, "error creating environment")
|
||||
|
||||
err = store.User().Create(
|
||||
&portainer.User{
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err, "error creating a user")
|
||||
|
||||
bouncer := testhelpers.NewTestRequestBouncer()
|
||||
|
||||
snapshotService := &mockSnapshotService{
|
||||
snapshotEndpointShouldSucceed: atomic.Bool{},
|
||||
}
|
||||
snapshotService.snapshotEndpointShouldSucceed.Store(true)
|
||||
|
||||
h := NewHandler(bouncer)
|
||||
h.DataStore = store
|
||||
h.SnapshotService = snapshotService
|
||||
|
||||
doPostRequest := func() {
|
||||
req := httptest.NewRequest(http.MethodPost, "/endpoints/snapshot", nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, rr.Code, "Status should be 204")
|
||||
|
||||
_, err := io.ReadAll(rr.Body)
|
||||
require.NoError(t, err, "ReadAll should not return error")
|
||||
}
|
||||
|
||||
doPostRequest()
|
||||
|
||||
// check that the endpoint has been immediately set to up
|
||||
endpoint, err = store.Endpoint().Endpoint(endpointID)
|
||||
require.NoError(t, err, "error getting endpoint")
|
||||
assert.Equal(t, portainer.EndpointStatusUp, endpoint.Status, "endpoint should be up (1) since mock snapshot returned ok")
|
||||
|
||||
// set the mock to return an error
|
||||
snapshotService.snapshotEndpointShouldSucceed.Store(false)
|
||||
doPostRequest()
|
||||
|
||||
// check that the endpoint has been immediately set to down
|
||||
endpoint, err = store.Endpoint().Endpoint(endpointID)
|
||||
require.NoError(t, err, "error getting endpoint")
|
||||
assert.Equal(t, portainer.EndpointStatusDown, endpoint.Status, "endpoint should be down (2) since mock snapshot returned error")
|
||||
}
|
||||
|
||||
var _ portainer.SnapshotService = &mockSnapshotService{}
|
||||
|
||||
type mockSnapshotService struct {
|
||||
snapshotEndpointShouldSucceed atomic.Bool
|
||||
}
|
||||
|
||||
func (s *mockSnapshotService) Start() {
|
||||
}
|
||||
|
||||
func (s *mockSnapshotService) SetSnapshotInterval(snapshotInterval string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error {
|
||||
if s.snapshotEndpointShouldSucceed.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("snapshot failed")
|
||||
}
|
||||
|
||||
func (s *mockSnapshotService) FillSnapshotData(endpoint *portainer.Endpoint, includeRaw bool) error {
|
||||
return nil
|
||||
}
|
||||
@@ -55,6 +55,10 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if r.RequestURI == "/" || strings.HasSuffix(r.RequestURI, ".html") {
|
||||
w.Header().Set("Permissions-Policy", strings.Join(permissions, ","))
|
||||
}
|
||||
|
||||
if !isHTML(r.Header["Accept"]) {
|
||||
w.Header().Set("Cache-Control", "max-age=31536000")
|
||||
} else {
|
||||
|
||||
70
api/http/handler/file/handler_test.go
Normal file
70
api/http/handler/file/handler_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package file_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/http/handler/file"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalServe(t *testing.T) {
|
||||
handler := file.NewHandler("", false, func() bool { return false })
|
||||
require.NotNil(t, handler)
|
||||
|
||||
request := func(path string) (*http.Request, *httptest.ResponseRecorder) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
handler.ServeHTTP(rr, req)
|
||||
return req, rr
|
||||
}
|
||||
|
||||
_, rr := request("/timeout.html")
|
||||
require.Equal(t, http.StatusTemporaryRedirect, rr.Result().StatusCode)
|
||||
loc, err := rr.Result().Location()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loc)
|
||||
require.Equal(t, "/", loc.Path)
|
||||
|
||||
_, rr = request("/")
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
}
|
||||
|
||||
func TestPermissionsPolicyHeader(t *testing.T) {
|
||||
handler := file.NewHandler("", false, func() bool { return false })
|
||||
require.NotNil(t, handler)
|
||||
|
||||
test := func(path string, exist bool) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, exist, rr.Result().Header.Get("Permissions-Policy") != "")
|
||||
}
|
||||
|
||||
test("/", true)
|
||||
test("/index.html", true)
|
||||
test("/api", false)
|
||||
test("/an/image.png", false)
|
||||
}
|
||||
|
||||
func TestRedirectInstanceDisabled(t *testing.T) {
|
||||
handler := file.NewHandler("", false, func() bool { return true })
|
||||
require.NotNil(t, handler)
|
||||
|
||||
test := func(path string) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, rr.Result().StatusCode)
|
||||
loc, err := rr.Result().Location()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loc)
|
||||
require.Equal(t, "/timeout.html", loc.Path)
|
||||
}
|
||||
|
||||
test("/")
|
||||
test("/index.html")
|
||||
}
|
||||
91
api/http/handler/file/permissions_list.go
Normal file
91
api/http/handler/file/permissions_list.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package file
|
||||
|
||||
var permissions = []string{
|
||||
"accelerometer=()",
|
||||
"ambient-light-sensor=()",
|
||||
"attribution-reporting=()",
|
||||
"autoplay=()",
|
||||
"battery=()",
|
||||
"browsing-topics=()",
|
||||
"camera=()",
|
||||
"captured-surface-control=()",
|
||||
"ch-device-memory=()",
|
||||
"ch-downlink=()",
|
||||
"ch-dpr=()",
|
||||
"ch-ect=()",
|
||||
"ch-prefers-color-scheme=()",
|
||||
"ch-prefers-reduced-motion=()",
|
||||
"ch-prefers-reduced-transparency=()",
|
||||
"ch-rtt=()",
|
||||
"ch-save-data=()",
|
||||
"ch-ua=()",
|
||||
"ch-ua-arch=()",
|
||||
"ch-ua-bitness=()",
|
||||
"ch-ua-form-factors=()",
|
||||
"ch-ua-full-version=()",
|
||||
"ch-ua-full-version-list=()",
|
||||
"ch-ua-mobile=()",
|
||||
"ch-ua-model=()",
|
||||
"ch-ua-platform=()",
|
||||
"ch-ua-platform-version=()",
|
||||
"ch-ua-wow64=()",
|
||||
"ch-viewport-height=()",
|
||||
"ch-viewport-width=()",
|
||||
"ch-width=()",
|
||||
"compute-pressure=()",
|
||||
"conversion-measurement=()",
|
||||
"cross-origin-isolated=()",
|
||||
"deferred-fetch=()",
|
||||
"deferred-fetch-minimal=()",
|
||||
"display-capture=()",
|
||||
"document-domain=()",
|
||||
"encrypted-media=()",
|
||||
"execution-while-not-rendered=()",
|
||||
"execution-while-out-of-viewport=()",
|
||||
"focus-without-user-activation=()",
|
||||
"fullscreen=()",
|
||||
"gamepad=()",
|
||||
"geolocation=()",
|
||||
"gyroscope=()",
|
||||
"hid=()",
|
||||
"identity-credentials-get=()",
|
||||
"idle-detection=()",
|
||||
"interest-cohort=()",
|
||||
"join-ad-interest-group=()",
|
||||
"keyboard-map=()",
|
||||
"language-detector=()",
|
||||
"local-fonts=()",
|
||||
"magnetometer=()",
|
||||
"microphone=()",
|
||||
"midi=()",
|
||||
"navigation-override=()",
|
||||
"otp-credentials=()",
|
||||
"payment=()",
|
||||
"picture-in-picture=()",
|
||||
"private-aggregation=()",
|
||||
"private-state-token-issuance=()",
|
||||
"private-state-token-redemption=()",
|
||||
"publickey-credentials-create=()",
|
||||
"publickey-credentials-get=()",
|
||||
"rewriter=()",
|
||||
"run-ad-auction=()",
|
||||
"screen-wake-lock=()",
|
||||
"serial=()",
|
||||
"shared-storage=()",
|
||||
"shared-storage-select-url=()",
|
||||
"speaker-selection=()",
|
||||
"storage-access=()",
|
||||
"summarizer=()",
|
||||
"sync-script=()",
|
||||
"sync-xhr=()",
|
||||
"translator=()",
|
||||
"trust-token-redemption=()",
|
||||
"unload=()",
|
||||
"usb=()",
|
||||
"vertical-scroll=()",
|
||||
"web-share=()",
|
||||
"window-management=()",
|
||||
"window-placement=()",
|
||||
"writer=()",
|
||||
"xr-spatial-tracking=()",
|
||||
}
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.33.0-rc1
|
||||
// @version 2.33.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInitDial(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer srv.Close()
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ package kubernetes
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLocalTransport(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
transport, err := NewLocalTransport(nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, transport.httpTransport.TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo
|
||||
|
||||
@@ -111,7 +111,7 @@ func (service *Service) PersistEdgeStack(
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack); err != nil {
|
||||
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -230,11 +230,11 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == endpointID {
|
||||
s.relations[i].EdgeStacks[edgeStackID] = true
|
||||
s.relations[i].EdgeStacks[edgeStack.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,3 +460,39 @@ func WithStacks(stacks []portainer.Stack) datastoreOption {
|
||||
d.stack = &stubStacksService{stacks: stacks}
|
||||
}
|
||||
}
|
||||
|
||||
type stubPendingActionService struct {
|
||||
actions []portainer.PendingAction
|
||||
dataservices.PendingActionsService
|
||||
}
|
||||
|
||||
func WithPendingActions(pendingActions []portainer.PendingAction) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
d.pendingActionsService = &stubPendingActionService{
|
||||
actions: pendingActions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubPendingActionService) ReadAll(predicates ...func(portainer.PendingAction) bool) ([]portainer.PendingAction, error) {
|
||||
filtered := s.actions
|
||||
|
||||
for _, predicate := range predicates {
|
||||
filtered = slicesx.Filter(filtered, predicate)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (s *stubPendingActionService) Delete(ID portainer.PendingActionID) error {
|
||||
actions := []portainer.PendingAction{}
|
||||
|
||||
for _, action := range s.actions {
|
||||
if action.ID != ID {
|
||||
actions = append(actions, action)
|
||||
}
|
||||
}
|
||||
s.actions = actions
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateConnectionForURL(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer srv.Close()
|
||||
|
||||
|
||||
@@ -71,10 +71,14 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
|
||||
isKubernetesEndpoint := endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
|
||||
|
||||
// EndpointStatusUp is only relevant for non-Kubernetes endpoints
|
||||
// Sometimes the endpoint is UP but the status is not updated in the database
|
||||
if !isKubernetesEndpoint {
|
||||
if endpoint.Status != portainer.EndpointStatusUp {
|
||||
// Edge environments check the heartbeat
|
||||
// Other environments check the endpoint status
|
||||
if endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
if !endpoint.Heartbeat {
|
||||
return
|
||||
}
|
||||
} else if endpoint.Status != portainer.EndpointStatusUp {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
||||
89
api/pendingactions/pendingactions_test.go
Normal file
89
api/pendingactions/pendingactions_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package pendingactions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExecute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *portainer.Endpoint
|
||||
pendingActions []portainer.PendingAction
|
||||
shouldExecute bool
|
||||
}{
|
||||
{
|
||||
name: "Edge endpoint with heartbeat should execute",
|
||||
// Create test endpoint
|
||||
endpoint: &portainer.Endpoint{
|
||||
ID: 1,
|
||||
Heartbeat: true,
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
EdgeID: "edge-1",
|
||||
},
|
||||
pendingActions: []portainer.PendingAction{
|
||||
{ID: 1, EndpointID: 1, Action: "test-action"},
|
||||
},
|
||||
shouldExecute: true,
|
||||
},
|
||||
{
|
||||
name: "Edge endpoint without heartbeat should not execute",
|
||||
endpoint: &portainer.Endpoint{
|
||||
ID: 2,
|
||||
EdgeID: "edge-2",
|
||||
Heartbeat: false,
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
},
|
||||
pendingActions: []portainer.PendingAction{
|
||||
{ID: 2, EndpointID: 2, Action: "test-action"},
|
||||
},
|
||||
shouldExecute: false,
|
||||
},
|
||||
{
|
||||
name: "Regular endpoint with status UP should execute",
|
||||
endpoint: &portainer.Endpoint{
|
||||
ID: 3,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
},
|
||||
pendingActions: []portainer.PendingAction{
|
||||
{ID: 3, EndpointID: 3, Action: "test-action"},
|
||||
},
|
||||
shouldExecute: true,
|
||||
},
|
||||
{
|
||||
name: "Regular endpoint with status DOWN should not execute",
|
||||
endpoint: &portainer.Endpoint{
|
||||
ID: 4,
|
||||
Status: portainer.EndpointStatusDown,
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
},
|
||||
pendingActions: []portainer.PendingAction{
|
||||
{ID: 4, EndpointID: 4, Action: "test-action"},
|
||||
},
|
||||
shouldExecute: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup services
|
||||
store := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{*tt.endpoint}), testhelpers.WithPendingActions(tt.pendingActions))
|
||||
service := NewService(store, nil)
|
||||
|
||||
// Execute
|
||||
service.execute(tt.endpoint.ID)
|
||||
|
||||
// Verify expectations
|
||||
pendingActions, _ := store.PendingActions().ReadAll()
|
||||
if tt.shouldExecute {
|
||||
assert.Equal(t, len(tt.pendingActions)-1, len(pendingActions))
|
||||
} else {
|
||||
assert.Equal(t, len(tt.pendingActions), len(pendingActions))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -122,14 +122,12 @@ type (
|
||||
Templates *string
|
||||
TLS *bool
|
||||
TLSSkipVerify *bool
|
||||
HasTLSCacert *bool
|
||||
TLSCacert *string
|
||||
TLSCert *string
|
||||
TLSKey *string
|
||||
HTTPDisabled *bool
|
||||
HTTPEnabled *bool
|
||||
SSL *bool
|
||||
SSLCert *string
|
||||
SSLKey *string
|
||||
Rollback *bool
|
||||
SnapshotInterval *string
|
||||
BaseURL *string
|
||||
@@ -1783,7 +1781,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.33.0-rc1"
|
||||
APIVersion = "2.33.1"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -203,6 +204,8 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
cloneErr := errors.New("failed to clone")
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
|
||||
@@ -57,8 +57,10 @@
|
||||
<information-panel ng-if="state.kubernetesEndpoint && (!state.edgeEndpoint || state.edgeAssociated)" title-text="Kubernetes features configuration">
|
||||
<span class="small text-muted vertical-center">
|
||||
<pr-icon icon="'wrench'" mode="'primary'"></pr-icon>
|
||||
You should configure the features available in this Kubernetes environment in the
|
||||
<a ui-sref="kubernetes.cluster.setup({endpointId: endpoint.Id})">Kubernetes configuration</a> view.
|
||||
<div>
|
||||
You should configure the features available in this Kubernetes environment in the
|
||||
<a ui-sref="kubernetes.cluster.setup({endpointId: endpoint.Id})">Kubernetes configuration</a> view.
|
||||
</div>
|
||||
</span>
|
||||
</information-panel>
|
||||
</div>
|
||||
|
||||
330
app/react/components/form-components/ReactSelect.test.tsx
Normal file
330
app/react/components/form-components/ReactSelect.test.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import selectEvent from '@/react/test-utils/react-select';
|
||||
|
||||
import { Select } from './ReactSelect';
|
||||
|
||||
describe('ReactSelect', () => {
|
||||
const mockOptions = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
{ value: 'option3', label: 'Option 3' },
|
||||
];
|
||||
|
||||
const mockGroupedOptions = [
|
||||
{
|
||||
label: 'Group 1',
|
||||
options: [
|
||||
{ value: 'g1-option1', label: 'Group 1 Option 1' },
|
||||
{ value: 'g1-option2', label: 'Group 1 Option 2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Group 2',
|
||||
options: [
|
||||
{ value: 'g2-option1', label: 'Group 2 Option 1' },
|
||||
{ value: 'g2-option2', label: 'Group 2 Option 2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('Select component', () => {
|
||||
it('should apply the correct size class', () => {
|
||||
const { container } = render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
size="sm"
|
||||
data-cy="test-select"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectContainer = container.querySelector(
|
||||
'.portainer-selector-root'
|
||||
);
|
||||
expect(selectContainer).toHaveClass('sm');
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
className="custom-class"
|
||||
data-cy="test-select"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectContainer = container.querySelector(
|
||||
'.portainer-selector-root'
|
||||
);
|
||||
expect(selectContainer).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should handle onChange event', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await selectEvent.select(input, 'Option 2', { user });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
mockOptions[1],
|
||||
expect.objectContaining({ action: 'select-option' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multi-select', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
isMulti
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await selectEvent.select(input, 'Option 1', { user });
|
||||
await selectEvent.select(input, 'Option 2', { user });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(2);
|
||||
expect(handleChange).toHaveBeenLastCalledWith(
|
||||
[mockOptions[0], mockOptions[1]],
|
||||
expect.objectContaining({ action: 'select-option' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should render with grouped options', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockGroupedOptions}
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await selectEvent.openMenu(input, { user });
|
||||
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 Option 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle disabled state', () => {
|
||||
const { container } = render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
isDisabled
|
||||
data-cy="test-select"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectContainer = container.querySelector(
|
||||
'.portainer-selector-root'
|
||||
);
|
||||
expect(selectContainer).toHaveClass('portainer-selector--is-disabled');
|
||||
});
|
||||
|
||||
it('should handle loading state', () => {
|
||||
const { container } = render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
isLoading
|
||||
data-cy="test-select"
|
||||
/>
|
||||
);
|
||||
|
||||
const loadingIndicator = container.querySelector(
|
||||
'.portainer-selector__loading-indicator'
|
||||
);
|
||||
expect(loadingIndicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should clear selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
isClearable
|
||||
value={mockOptions[0]}
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await selectEvent.clearFirst(input, { user });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
null,
|
||||
expect.objectContaining({ action: 'clear' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty options array', () => {
|
||||
render(<Select id="test-select" options={[]} data-cy="test-select" />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle undefined options', () => {
|
||||
render(
|
||||
<Select id="test-select" options={undefined} data-cy="test-select" />
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component integration', () => {
|
||||
it('should switch between regular and paginated select based on options count', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// First render with few options - should use regular Select
|
||||
const { rerender } = render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
let input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
// Regular select should render all 3 options immediately
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
|
||||
// Close menu
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
// Now rerender with many options - should switch to TooManyResultsSelector
|
||||
const manyOptions = Array.from({ length: 1001 }, (_, i) => ({
|
||||
value: `option${i}`,
|
||||
label: `Option ${i}`,
|
||||
}));
|
||||
|
||||
rerender(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={manyOptions}
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
// Paginated select should only render first page (100 items max)
|
||||
// Check that first few options are present
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Count total rendered options - should be limited to PAGE_SIZE (100)
|
||||
// React-select uses divs with class portainer-selector__option for options
|
||||
const renderedOptions = document.querySelectorAll(
|
||||
'.portainer-selector__option'
|
||||
);
|
||||
expect(renderedOptions.length).toBeLessThanOrEqual(100);
|
||||
expect(renderedOptions.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify that options beyond page size are NOT rendered
|
||||
expect(screen.queryByText('Option 999')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render creatable mode when isCreatable prop is true', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={mockOptions}
|
||||
isCreatable
|
||||
onChange={handleChange}
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
// Type a new value that doesn't exist in options
|
||||
await user.type(input, 'Brand New Option');
|
||||
|
||||
// Should show create option (may appear in multiple places)
|
||||
await waitFor(() => {
|
||||
const createOptions = screen.getAllByText(/Create "Brand New Option"/);
|
||||
expect(createOptions.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve props when switching to TooManyResultsSelector', () => {
|
||||
const handleChange = vi.fn();
|
||||
const manyOptions = Array.from({ length: 1001 }, (_, i) => ({
|
||||
value: `option${i}`,
|
||||
label: `Option ${i}`,
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<Select
|
||||
id="test-select"
|
||||
options={manyOptions}
|
||||
onChange={handleChange}
|
||||
placeholder="Select an option"
|
||||
isSearchable
|
||||
isClearable
|
||||
data-cy="test-select"
|
||||
inputId="test-input"
|
||||
/>
|
||||
);
|
||||
|
||||
// Should use TooManyResultsSelector for large datasets
|
||||
const selectContainer = container.querySelector(
|
||||
'.portainer-selector-root'
|
||||
);
|
||||
expect(selectContainer).toBeInTheDocument();
|
||||
|
||||
// Should preserve data-cy attribute
|
||||
const input = screen.getByRole('combobox');
|
||||
expect(input).toHaveAttribute('data-cy', 'test-select');
|
||||
|
||||
// Should preserve id
|
||||
expect(input).toHaveAttribute('id', 'test-input');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import ReactSelectCreatable, {
|
||||
CreatableProps as ReactSelectCreatableProps,
|
||||
} from 'react-select/creatable';
|
||||
import ReactSelectAsync, {
|
||||
AsyncProps as ReactSelectAsyncProps,
|
||||
} from 'react-select/async';
|
||||
import {
|
||||
AsyncPaginate as ReactSelectAsyncPaginate,
|
||||
AsyncPaginateProps as ReactSelectAsyncPaginateProps,
|
||||
} from 'react-select-async-paginate';
|
||||
import ReactSelect, {
|
||||
components,
|
||||
GroupBase,
|
||||
@@ -18,6 +19,9 @@ import ReactSelectType from 'react-select/dist/declarations/src/Select';
|
||||
import './ReactSelect.css';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_OPTIONS_WITHOUT_PAGINATION = 1000;
|
||||
|
||||
interface DefaultOption {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -86,7 +90,7 @@ export function Select<
|
||||
Group
|
||||
>(dataCy, componentsProp);
|
||||
|
||||
if ((options?.length || 0) > 1000) {
|
||||
if ((options?.length || 0) > MAX_OPTIONS_WITHOUT_PAGINATION) {
|
||||
return (
|
||||
<TooManyResultsSelector
|
||||
size={size}
|
||||
@@ -143,7 +147,7 @@ export function Async<
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: ReactSelectAsyncProps<Option, IsMulti, Group> & {
|
||||
}: ReactSelectAsyncPaginateProps<Option, Group, unknown, IsMulti> & {
|
||||
size?: 'sm' | 'md';
|
||||
} & AutomationTestingProps) {
|
||||
const { 'data-cy': dataCy, components: componentsProp, ...rest } = props;
|
||||
@@ -155,7 +159,7 @@ export function Async<
|
||||
>(dataCy, componentsProp);
|
||||
|
||||
return (
|
||||
<ReactSelectAsync
|
||||
<ReactSelectAsyncPaginate
|
||||
className={clsx(className, 'portainer-selector-root', size)}
|
||||
classNamePrefix="portainer-selector"
|
||||
components={memoizedComponents}
|
||||
@@ -173,22 +177,29 @@ export function TooManyResultsSelector<
|
||||
options,
|
||||
isLoading,
|
||||
getOptionValue,
|
||||
getOptionLabel,
|
||||
isItemVisible = (item, search) =>
|
||||
!!getOptionValue?.(item).toLowerCase().includes(search.toLowerCase()),
|
||||
search.trim() === '' ||
|
||||
!!getOptionLabel?.(item).toLowerCase().includes(search.toLowerCase()),
|
||||
...props
|
||||
}: RegularProps<Option, IsMulti, Group> & {
|
||||
isItemVisible?: (item: Option, search: string) => boolean;
|
||||
}) {
|
||||
const defaultOptions = useMemo(() => options?.slice(0, 100), [options]);
|
||||
|
||||
return (
|
||||
<Async
|
||||
isLoading={isLoading}
|
||||
getOptionValue={getOptionValue}
|
||||
loadOptions={(search: string) =>
|
||||
filterOptions<Option, Group>(options, isItemVisible, search)
|
||||
loadOptions={(
|
||||
search: string,
|
||||
loadedOptions: OptionsOrGroups<Option, Group> | undefined
|
||||
) =>
|
||||
filterOptions<Option, Group>(
|
||||
options,
|
||||
isItemVisible,
|
||||
search,
|
||||
loadedOptions
|
||||
)
|
||||
}
|
||||
defaultOptions={defaultOptions}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
@@ -201,17 +212,21 @@ function filterOptions<
|
||||
>(
|
||||
options: OptionsOrGroups<Option, Group> | undefined,
|
||||
isItemVisible: (item: Option, search: string) => boolean,
|
||||
search: string
|
||||
): Promise<OptionsOrGroups<Option, Group> | undefined> {
|
||||
return Promise.resolve<OptionsOrGroups<Option, Group> | undefined>(
|
||||
options
|
||||
?.filter((item) =>
|
||||
isGroup(item)
|
||||
? item.options.some((ni) => isItemVisible(ni, search))
|
||||
: isItemVisible(item, search)
|
||||
)
|
||||
.slice(0, 100)
|
||||
search: string,
|
||||
loadedOptions?: OptionsOrGroups<Option, Group>
|
||||
) {
|
||||
const filteredOptions = options?.filter((item) =>
|
||||
isGroup(item)
|
||||
? item.options.some((ni) => isItemVisible(ni, search))
|
||||
: isItemVisible(item, search)
|
||||
);
|
||||
|
||||
const offset = loadedOptions?.length ?? 0;
|
||||
|
||||
return {
|
||||
options: filteredOptions?.slice(offset, offset + PAGE_SIZE) ?? [],
|
||||
hasMore: (filteredOptions?.length ?? 0) > offset + PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function isGroup<
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { CellContext, Row } from '@tanstack/react-table';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { isoDate, truncate } from '@/portainer/filters/filters';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { SystemBadge } from '@@/Badge/SystemBadge';
|
||||
import { filterHOC } from '@@/datatables/Filter';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
import { Application } from './types';
|
||||
import { helper } from './columns.helper';
|
||||
@@ -40,15 +42,45 @@ function NamespaceCell({ row, getValue }: CellContext<Application, string>) {
|
||||
export const image = helper.accessor('Image', {
|
||||
header: 'Image',
|
||||
cell: ({ row: { original: item } }) => (
|
||||
<>
|
||||
{truncate(item.Image, 64)}
|
||||
{item.Containers && item.Containers?.length > 1 && (
|
||||
<>+ {item.Containers.length - 1}</>
|
||||
)}
|
||||
</>
|
||||
<ImageCell image={item.Image} imageCount={item.Containers?.length || 0} />
|
||||
),
|
||||
});
|
||||
|
||||
function ImageCell({
|
||||
image,
|
||||
imageCount,
|
||||
}: {
|
||||
image: string;
|
||||
imageCount: number;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const isTruncated = isWidthTruncated();
|
||||
|
||||
const imageElement = (
|
||||
<div className="inline-block max-w-xs truncate" ref={contentRef}>
|
||||
{image}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<TooltipWithChildren message={image}>{imageElement}</TooltipWithChildren>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{imageElement}
|
||||
{imageCount > 1 && <> + {imageCount - 1}</>}
|
||||
</div>
|
||||
);
|
||||
|
||||
function isWidthTruncated() {
|
||||
const el = contentRef.current;
|
||||
return el && el.scrollWidth > el.clientWidth;
|
||||
}
|
||||
}
|
||||
|
||||
export const appType = helper.accessor('ApplicationType', {
|
||||
header: 'Application type',
|
||||
meta: {
|
||||
|
||||
@@ -205,6 +205,8 @@ export enum EnvironmentCreationTypes {
|
||||
export enum ContainerEngine {
|
||||
Docker = 'docker',
|
||||
Podman = 'podman',
|
||||
// an empty container engine means that the endpoint is a Kubernetes endpoint
|
||||
Kubernetes = '',
|
||||
}
|
||||
|
||||
export enum PlatformType {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { HttpResponse } from 'msw';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { server, http } from '@/setup-tests/server';
|
||||
|
||||
import { WizardKubernetes } from './WizardKubernetes';
|
||||
|
||||
function renderComponent() {
|
||||
// minimal settings so EdgeAgentForm can render
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({
|
||||
AgentSecret: 'secret',
|
||||
EdgePortainerUrl: 'https://example.com',
|
||||
Edge: {
|
||||
PingInterval: 60,
|
||||
SnapshotInterval: 60,
|
||||
CommandInterval: 60,
|
||||
AsyncMode: false,
|
||||
TunnelServerAddress: 'portainer.test:8000',
|
||||
},
|
||||
})
|
||||
),
|
||||
http.get('/api/custom_templates', () => HttpResponse.json([])),
|
||||
http.get('/api/system/status', () =>
|
||||
HttpResponse.json({ Version: '2.19.0', Edition: 'CE', InstanceID: '1' })
|
||||
),
|
||||
http.get('/api/endpoints', () =>
|
||||
HttpResponse.json([], {
|
||||
headers: {
|
||||
'x-total-available': '0',
|
||||
'x-total-count': '0',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const Wrapped = withTestQueryProvider(() => (
|
||||
<WizardKubernetes onCreate={() => {}} />
|
||||
));
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
describe('WizardKubernetes', () => {
|
||||
test('renders Edge Agent Standard form when selected', async () => {
|
||||
const { getByText, queryByTestId, findByTestId } = renderComponent();
|
||||
|
||||
// select Edge Agent Standard
|
||||
await userEvent.click(getByText('Edge Agent Standard'));
|
||||
|
||||
// verify submit button is visible (smallest sanity check for setup)
|
||||
await expect(
|
||||
findByTestId('edge-agent-form-submit-button')
|
||||
).resolves.toBeVisible();
|
||||
expect(
|
||||
queryByTestId('endpointCreate-portainerServerUrlInput')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('submits ContainerEngine as empty string for Kubernetes', async () => {
|
||||
let observedEntries: Array<[string, string]> = [];
|
||||
|
||||
server.use(
|
||||
http.post('/api/endpoints', async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
observedEntries = Array.from(form.entries()).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'string' ? value : 'binary',
|
||||
]);
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
const { getByText, getByTestId, findByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByText('Edge Agent Standard'));
|
||||
|
||||
await userEvent.type(getByTestId('environmentCreate-nameInput'), 'k8s-env');
|
||||
|
||||
const submitBtn = await findByTestId('edge-agent-form-submit-button');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await userEvent.click(submitBtn);
|
||||
|
||||
// assert POST happened and ContainerEngine key exists with empty string
|
||||
await waitFor(() => {
|
||||
expect(observedEntries.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
observedEntries.some(([k, v]) => k === 'ContainerEngine' && v === '')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import { useState } from 'react';
|
||||
import { Zap, UploadCloud } from 'lucide-react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
ContainerEngine,
|
||||
Environment,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
@@ -98,6 +101,7 @@ export function WizardKubernetes({ onCreate }: Props) {
|
||||
onCreate(environment, 'kubernetesEdgeAgentStandard')
|
||||
}
|
||||
commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]}
|
||||
containerEngine={ContainerEngine.Kubernetes}
|
||||
/>
|
||||
);
|
||||
case 'edgeAgentAsync':
|
||||
@@ -108,6 +112,7 @@ export function WizardKubernetes({ onCreate }: Props) {
|
||||
onCreate(environment, 'kubernetesEdgeAgentAsync')
|
||||
}
|
||||
commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]}
|
||||
containerEngine={ContainerEngine.Kubernetes}
|
||||
/>
|
||||
);
|
||||
case 'kubeconfig':
|
||||
|
||||
@@ -77,7 +77,7 @@ export function AutoUpdateSettings({
|
||||
checked={value.ForcePullImage || false}
|
||||
label="Re-pull image"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
tooltip="If enabled, then when redeploy is triggered via the webhook or polling, if there's a newer image with the tag that you've specified (e.g. changeable development builds), it's pulled and redeployed. If you haven't specified a tag, or have specified 'latest' as the tag, then the image with the tag 'latest' is pulled and redeployed."
|
||||
tooltip="If enabled, then when redeploy is triggered via the webhook or polling, if there's a newer image with the tag that you've specified (e.g. changeable development builds), it's pulled and redeployed. If you haven't specified a tag, or have specified 'latest' as the tag, then the image with the tag 'latest' is pulled and redeployed. With relative path enabled, it also redeploys when mounted files (not just the compose file) change."
|
||||
onChange={(value) => onChange({ ForcePullImage: value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -107,7 +107,22 @@ export function AutoUpdateSettings({
|
||||
the cluster being overwritten.
|
||||
</p>
|
||||
</>
|
||||
) : undefined
|
||||
) : (
|
||||
<p>
|
||||
If enabled, then when redeploy is triggered via the webhook or
|
||||
polling, the stack behavior depends on the stack type:
|
||||
<br />
|
||||
<strong>Regular stacks:</strong> Redeploy whenever triggered,
|
||||
without checking for docker-compose file changes
|
||||
<br />
|
||||
<strong>Edge stacks:</strong> Redeploy only when the
|
||||
docker-compose file in the Git repository has changed. Commits
|
||||
that change unrelated files or mounted files (via relative paths)
|
||||
do not trigger redeployment. Currently, this option does not
|
||||
change the redeployment behavior, and it remains a temporary
|
||||
solution until a more complete behavior is added later.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useField } from 'formik';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Switch } from '@@/form-components/SwitchField/Switch';
|
||||
|
||||
const fieldKey = 'OpenAIIntegration';
|
||||
|
||||
export function EnableOpenAIIntegrationSwitch() {
|
||||
const [inputProps, meta, helpers] = useField<boolean>(fieldKey);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
inputId="experimental_openAI"
|
||||
label="Enable OpenAI integration"
|
||||
size="medium"
|
||||
errors={meta.error}
|
||||
>
|
||||
<Switch
|
||||
id="experimental_openAI"
|
||||
data-cy="enable-openai-integration-switch"
|
||||
name={fieldKey}
|
||||
className="space-right"
|
||||
checked={inputProps.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
function handleChange(enable: boolean) {
|
||||
helpers.setValue(enable);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,9 @@ import { useUpdateExperimentalSettingsMutation } from '@/react/portainer/setting
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { EnableOpenAIIntegrationSwitch } from './EnableOpenAIIntegrationSwitch';
|
||||
interface FormValues {}
|
||||
|
||||
interface FormValues {
|
||||
OpenAIIntegration: boolean;
|
||||
}
|
||||
const validation = yup.object({
|
||||
OpenAIIntegration: yup.boolean(),
|
||||
});
|
||||
const validation = yup.object({});
|
||||
|
||||
interface Props {
|
||||
settings: ExperimentalFeatures;
|
||||
@@ -30,24 +25,19 @@ export function ExperimentalFeaturesSettingsForm({ settings }: Props) {
|
||||
|
||||
const { mutate: updateSettings } = mutation;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(variables: FormValues) => {
|
||||
updateSettings(
|
||||
{
|
||||
OpenAIIntegration: variables.OpenAIIntegration,
|
||||
const handleSubmit = useCallback(() => {
|
||||
updateSettings(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess(
|
||||
'Success',
|
||||
'Successfully updated experimental features settings'
|
||||
);
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess(
|
||||
'Success',
|
||||
'Successfully updated experimental features settings'
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[updateSettings]
|
||||
);
|
||||
}
|
||||
);
|
||||
}, [updateSettings]);
|
||||
|
||||
return (
|
||||
<Formik<FormValues>
|
||||
@@ -76,8 +66,6 @@ export function ExperimentalFeaturesSettingsForm({ settings }: Props) {
|
||||
experimental feature will prevent access to it.
|
||||
</div>
|
||||
|
||||
<EnableOpenAIIntegrationSwitch />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { server } from '@/setup-tests/server';
|
||||
|
||||
import { SettingsView } from './SettingsView';
|
||||
|
||||
describe('SettingsView', () => {
|
||||
function setupMocks() {
|
||||
// Mock the settings API endpoints
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({
|
||||
LogoURL: '',
|
||||
SnapshotInterval: '5m',
|
||||
EnableTelemetry: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get('/api/settings/experimental', () =>
|
||||
HttpResponse.json({
|
||||
experimentalFeatures: {},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Mock public settings for feature flags
|
||||
server.use(
|
||||
http.get('/api/settings/public', () =>
|
||||
HttpResponse.json({
|
||||
Features: {
|
||||
'auto-patch': false,
|
||||
'disable-roles-sync': false,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Mock SSL settings
|
||||
server.use(
|
||||
http.get('/api/ssl', () =>
|
||||
HttpResponse.json({
|
||||
HTTPSOnly: false,
|
||||
SelfSigned: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Mock debug settings
|
||||
server.use(
|
||||
http.get('/api/support/debug_log', () =>
|
||||
HttpResponse.json({
|
||||
LogLevel: 'INFO',
|
||||
EnableProfiling: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Mock backup S3 settings
|
||||
server.use(
|
||||
http.get('/api/backup/s3/settings', () =>
|
||||
HttpResponse.json({
|
||||
Enabled: false,
|
||||
AccessKey: '',
|
||||
SecretKey: '',
|
||||
Region: '',
|
||||
Bucket: '',
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withUserProvider(withTestRouter(SettingsView))
|
||||
);
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
describe('Experimental Features', () => {
|
||||
test('should NOT render ExperimentalFeatures component in CE edition', async () => {
|
||||
setupMocks();
|
||||
const { queryByText } = renderComponent();
|
||||
|
||||
// Check that the ExperimentalFeatures component is NOT rendered
|
||||
const experimentalFeaturesTitle = queryByText('Experimental features');
|
||||
expect(experimentalFeaturesTitle).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -99,9 +99,7 @@ export interface DefaultRegistry {
|
||||
Hide: boolean;
|
||||
}
|
||||
|
||||
export interface ExperimentalFeatures {
|
||||
OpenAIIntegration: boolean;
|
||||
}
|
||||
export interface ExperimentalFeatures {}
|
||||
|
||||
export interface Settings {
|
||||
LogoURL: string;
|
||||
|
||||
48
go.mod
48
go.mod
@@ -7,6 +7,7 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RoaringBitmap/roaring/v2 v2.5.0
|
||||
github.com/VictoriaMetrics/fastcache v1.12.0
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1
|
||||
@@ -48,23 +49,22 @@ require (
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/negroni v1.0.0
|
||||
github.com/viney-shih/go-lock v1.1.1
|
||||
go.etcd.io/bbolt v1.4.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
|
||||
golang.org/x/mod v0.25.0
|
||||
golang.org/x/oauth2 v0.29.0
|
||||
golang.org/x/sync v0.15.0
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
golang.org/x/sync v0.16.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
helm.sh/helm/v3 v3.18.4
|
||||
k8s.io/api v0.33.2
|
||||
k8s.io/apimachinery v0.33.2
|
||||
k8s.io/cli-runtime v0.33.2
|
||||
k8s.io/client-go v0.33.2
|
||||
k8s.io/kubectl v0.33.2
|
||||
helm.sh/helm/v3 v3.18.5
|
||||
k8s.io/api v0.33.3
|
||||
k8s.io/apimachinery v0.33.3
|
||||
k8s.io/cli-runtime v0.33.3
|
||||
k8s.io/client-go v0.33.3
|
||||
k8s.io/kubectl v0.33.3
|
||||
k8s.io/kubelet v0.33.2
|
||||
k8s.io/metrics v0.33.2
|
||||
k8s.io/metrics v0.33.3
|
||||
oras.land/oras-go/v2 v2.6.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
|
||||
)
|
||||
@@ -86,7 +86,6 @@ require (
|
||||
github.com/ProtonMail/go-crypto v1.1.3 // indirect
|
||||
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
||||
github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
@@ -157,7 +156,7 @@ require (
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
@@ -243,6 +242,7 @@ require (
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rubenv/sql-migrate v1.8.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
@@ -253,7 +253,7 @@ require (
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
|
||||
@@ -291,10 +291,12 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
go.uber.org/mock v0.5.2 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.3 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/term v0.33.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
@@ -304,10 +306,10 @@ require (
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
k8s.io/apiextensions-apiserver v0.33.2 // indirect
|
||||
k8s.io/apiserver v0.33.2 // indirect
|
||||
k8s.io/component-base v0.33.2 // indirect
|
||||
k8s.io/component-helpers v0.33.2 // indirect
|
||||
k8s.io/apiextensions-apiserver v0.33.3 // indirect
|
||||
k8s.io/apiserver v0.33.3 // indirect
|
||||
k8s.io/component-base v0.33.3 // indirect
|
||||
k8s.io/component-helpers v0.33.3 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
|
||||
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||
@@ -316,6 +318,6 @@ require (
|
||||
sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
sigs.k8s.io/yaml v1.5.0 // indirect
|
||||
tags.cncf.io/container-device-interface v1.0.1 // indirect
|
||||
)
|
||||
|
||||
99
go.sum
99
go.sum
@@ -49,9 +49,9 @@ github.com/VictoriaMetrics/fastcache v1.12.0 h1:vnVi/y9yKDcD9akmc4NqAoqgQhJrOwUF
|
||||
github.com/VictoriaMetrics/fastcache v1.12.0/go.mod h1:tjiYeEfYXCqacuvYw/7UoDIeJaNxq6132xHICNP77w8=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
@@ -206,6 +206,8 @@ github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN
|
||||
github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/buildx v0.24.0 h1:qiD+xktY+Fs3R79oz8M+7pbhip78qGLx6LBuVmyb+64=
|
||||
github.com/docker/buildx v0.24.0/go.mod h1:vYkdBUBjFo/i5vUE0mkajGlk03gE0T/HaGXXhgIxo8E=
|
||||
github.com/docker/cli v28.2.1+incompatible h1:AYyTcuwvhl9dXdyCiXlOGXiIqSNYzTmaDNpxIISPGsM=
|
||||
@@ -310,8 +312,8 @@ github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqw
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -662,6 +664,8 @@ github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2N
|
||||
github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
@@ -698,8 +702,9 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
|
||||
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431 h1:XTHrT015sxHyJ5FnQ0AeemSspZWaDq7DoTRW0EVsDCE=
|
||||
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c h1:2EejZtjFjKJGk71ANb+wtFK5EjUzUkEM3R0xnp559xg=
|
||||
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -782,6 +787,8 @@ github.com/zmap/zlint/v3 v3.6.4 h1:r2kHfRF7mIsxW0IH4Og2iZnrlpCLTZBFjnXy1x/ZnZI=
|
||||
github.com/zmap/zlint/v3 v3.6.4/go.mod h1:KQLVUquVaO5YJDl5a4k/7RPIbIW2v66+sRoBPNZusI8=
|
||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
@@ -838,6 +845,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
|
||||
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -856,8 +867,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -889,8 +900,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
@@ -907,8 +918,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -944,8 +955,10 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -957,8 +970,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -973,8 +986,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -985,8 +998,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -1006,7 +1019,6 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
|
||||
gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU=
|
||||
@@ -1042,34 +1054,34 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ=
|
||||
helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI=
|
||||
k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY=
|
||||
k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs=
|
||||
k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8=
|
||||
k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8=
|
||||
k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY=
|
||||
k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
|
||||
k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4=
|
||||
k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M=
|
||||
k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y=
|
||||
k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88=
|
||||
k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E=
|
||||
k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo=
|
||||
k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0=
|
||||
k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k=
|
||||
k8s.io/component-helpers v0.33.2 h1:AjCtYzst11NV8ensxV/2LEEXRwctqS7Bs44bje9Qcnw=
|
||||
k8s.io/component-helpers v0.33.2/go.mod h1:PsPpiCk74n8pGWp1d6kjK/iSKBTyQfIacv02BNkMenU=
|
||||
helm.sh/helm/v3 v3.18.5 h1:Cc3Z5vd6kDrZq9wO9KxKLNEickiTho6/H/dBNRVSos4=
|
||||
helm.sh/helm/v3 v3.18.5/go.mod h1:L/dXDR2r539oPlFP1PJqKAC1CUgqHJDLkxKpDGrWnyg=
|
||||
k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8=
|
||||
k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE=
|
||||
k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs=
|
||||
k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8=
|
||||
k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA=
|
||||
k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
|
||||
k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4=
|
||||
k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E=
|
||||
k8s.io/cli-runtime v0.33.3 h1:Dgy4vPjNIu8LMJBSvs8W0LcdV0PX/8aGG1DA1W8lklA=
|
||||
k8s.io/cli-runtime v0.33.3/go.mod h1:yklhLklD4vLS8HNGgC9wGiuHWze4g7x6XQZ+8edsKEo=
|
||||
k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA=
|
||||
k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg=
|
||||
k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA=
|
||||
k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4=
|
||||
k8s.io/component-helpers v0.33.3 h1:fjWVORSQfI0WKzPeIFSju/gMD9sybwXBJ7oPbqQu6eM=
|
||||
k8s.io/component-helpers v0.33.3/go.mod h1:7iwv+Y9Guw6X4RrnNQOyQlXcvJrVjPveHVqUA5dm31c=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
|
||||
k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y=
|
||||
k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI=
|
||||
k8s.io/kubectl v0.33.3 h1:r/phHvH1iU7gO/l7tTjQk2K01ER7/OAJi8uFHHyWSac=
|
||||
k8s.io/kubectl v0.33.3/go.mod h1:euj2bG56L6kUGOE/ckZbCoudPwuj4Kud7BR0GzyNiT0=
|
||||
k8s.io/kubelet v0.33.2 h1:wxEau5/563oJb3j3KfrCKlNWWx35YlSgDLOYUBCQ0pg=
|
||||
k8s.io/kubelet v0.33.2/go.mod h1:way8VCDTUMiX1HTOvJv7M3xS/xNysJI6qh7TOqMe5KM=
|
||||
k8s.io/metrics v0.33.2 h1:gNCBmtnUMDMCRg9Ly5ehxP3OdKISMsOnh1vzk01iCgE=
|
||||
k8s.io/metrics v0.33.2/go.mod h1:yxoAosKGRsZisv3BGekC5W6T1J8XSV+PoUEevACRv7c=
|
||||
k8s.io/metrics v0.33.3 h1:9CcqBz15JZfISqwca33gdHS8I6XfsK1vA8WUdEnG70g=
|
||||
k8s.io/metrics v0.33.3/go.mod h1:Aw+cdg4AYHw0HvUY+lCyq40FOO84awrqvJRTw0cmXDs=
|
||||
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
|
||||
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
|
||||
@@ -1085,8 +1097,9 @@ sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
|
||||
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
|
||||
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 h1:SqYE5+A2qvRhErbsXFfUEUmpWEKxxRSMgGLkvRAFOV4=
|
||||
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78/go.mod h1:B7Wf0Ya4DHF9Yw+qfZuJijQYkWicqDa+79Ytmmq3Kjg=
|
||||
tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc=
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.33.0-rc1",
|
||||
"version": "2.33.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
@@ -125,6 +125,7 @@
|
||||
"react-is": "^17.0.2",
|
||||
"react-json-view-lite": "^1.2.1",
|
||||
"react-select": "^5.2.1",
|
||||
"react-select-async-paginate": "^0.7.11",
|
||||
"sanitize-html": "^2.8.1",
|
||||
"spinkit": "^2.0.1",
|
||||
"strip-ansi": "^6.0.0",
|
||||
|
||||
@@ -35,10 +35,10 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/libhelm/cache"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/registryhttp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/registry"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
// IsOCIRegistry returns true if the registry is an OCI registry (not nil), false if it's an HTTP repository (nil)
|
||||
@@ -140,14 +140,6 @@ func authenticateChartSource(actionConfig *action.Configuration, registry *porta
|
||||
return errors.Wrap(err, "registry credential validation failed")
|
||||
}
|
||||
|
||||
// No authentication required
|
||||
if !registry.Authentication {
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Msg("No OCI registry authentication required")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache Strategy Decision: Use registry ID as cache key
|
||||
// This provides optimal rate limiting protection since each registry only gets
|
||||
// logged into once per Portainer instance, regardless of how many users access it.
|
||||
@@ -180,14 +172,14 @@ func authenticateChartSource(actionConfig *action.Configuration, registry *porta
|
||||
Str("context", "HelmClient").
|
||||
Msg("Cache miss - creating new registry client")
|
||||
|
||||
registryClient, err := loginToOCIRegistry(registry)
|
||||
registryClient, err := createOCIRegistryClient(registry)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("registry_url", registry.URL).
|
||||
Err(err).
|
||||
Msg("Failed to login to registry")
|
||||
return errors.Wrap(err, "failed to login to registry")
|
||||
Msg("Failed to create registry client")
|
||||
return errors.Wrap(err, "failed to create registry client")
|
||||
}
|
||||
|
||||
// Cache the client if login was successful (registry ID-based key)
|
||||
@@ -230,11 +222,13 @@ func configureOCIChartPathOptions(chartPathOptions *action.ChartPathOptions, reg
|
||||
}
|
||||
}
|
||||
|
||||
// loginToOCIRegistry performs registry login for OCI-based registries using Helm SDK
|
||||
// createOCIRegistryClient creates and optionally authenticates a registry client for OCI-based registries
|
||||
// Handles both authenticated and unauthenticated registries with proper TLS configuration
|
||||
// Tries to get a cached registry client if available, otherwise creates and caches a new one
|
||||
func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client, error) {
|
||||
if IsHTTPRepository(portainerRegistry) || !portainerRegistry.Authentication {
|
||||
return nil, nil // No authentication needed
|
||||
func createOCIRegistryClient(portainerRegistry *portainer.Registry) (*registry.Client, error) {
|
||||
// Handle nil registry (HTTP repository)
|
||||
if portainerRegistry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Check cache first using registry ID-based key
|
||||
@@ -243,32 +237,70 @@ func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "loginToRegistry").
|
||||
Str("context", "HelmClient").
|
||||
Int("registry_id", int(portainerRegistry.ID)).
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Msg("Attempting to login to OCI registry")
|
||||
Bool("authentication", portainerRegistry.Authentication).
|
||||
Msg("Creating OCI registry client")
|
||||
|
||||
registryClient, err := registry.NewClient(registry.ClientOptHTTPClient(retry.DefaultClient))
|
||||
// Create an HTTP client with proper TLS configuration
|
||||
httpClient, usePlainHTTP, err := registryhttp.CreateClient(portainerRegistry)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Err(err).
|
||||
Msg("Failed to create HTTP client for registry")
|
||||
return nil, errors.Wrap(err, "failed to create HTTP client for registry")
|
||||
}
|
||||
|
||||
clientOptions := []registry.ClientOption{
|
||||
registry.ClientOptHTTPClient(httpClient),
|
||||
}
|
||||
|
||||
if usePlainHTTP {
|
||||
clientOptions = append(clientOptions, registry.ClientOptPlainHTTP())
|
||||
}
|
||||
|
||||
registryClient, err := registry.NewClient(clientOptions...)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Err(err).
|
||||
Msg("Failed to create registry client")
|
||||
return nil, errors.Wrap(err, "failed to create registry client")
|
||||
}
|
||||
|
||||
loginOpts := []registry.LoginOption{
|
||||
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
|
||||
// Only perform login if authentication is enabled
|
||||
if portainerRegistry.Authentication {
|
||||
loginOpts := []registry.LoginOption{
|
||||
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
|
||||
}
|
||||
|
||||
err = registryClient.Login(portainerRegistry.URL, loginOpts...)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Err(err).
|
||||
Msg("Failed to login to registry")
|
||||
return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "createOCIRegistryClient").
|
||||
Int("registry_id", int(portainerRegistry.ID)).
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Msg("Successfully logged in to OCI registry")
|
||||
} else {
|
||||
log.Debug().
|
||||
Str("context", "createOCIRegistryClient").
|
||||
Int("registry_id", int(portainerRegistry.ID)).
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Msg("Created unauthenticated OCI registry client")
|
||||
}
|
||||
|
||||
err = registryClient.Login(portainerRegistry.URL, loginOpts...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "loginToRegistry").
|
||||
Int("registry_id", int(portainerRegistry.ID)).
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Msg("Successfully logged in to OCI registry")
|
||||
|
||||
// Cache using registry ID-based key
|
||||
cache.SetCachedRegistryClientByID(portainerRegistry.ID, registryClient)
|
||||
|
||||
return registryClient, nil
|
||||
|
||||
@@ -6,12 +6,17 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
helmregistrycache "github.com/portainer/portainer/pkg/libhelm/cache"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/registry"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
func TestIsOCIRegistry(t *testing.T) {
|
||||
t.Run("should return false for nil registry (HTTP repo)", func(t *testing.T) {
|
||||
assert.False(t, IsOCIRegistry(nil))
|
||||
@@ -188,19 +193,40 @@ func TestLoginToOCIRegistry(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
t.Run("should return nil for HTTP repository (nil registry)", func(t *testing.T) {
|
||||
client, err := loginToOCIRegistry(nil)
|
||||
client, err := createOCIRegistryClient(nil)
|
||||
is.NoError(err)
|
||||
is.Nil(client)
|
||||
})
|
||||
|
||||
t.Run("should return nil for registry with auth disabled", func(t *testing.T) {
|
||||
t.Run("should return client for registry with auth disabled (for TLS support)", func(t *testing.T) {
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io",
|
||||
Authentication: false,
|
||||
}
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
client, err := createOCIRegistryClient(registry)
|
||||
is.NoError(err)
|
||||
is.Nil(client)
|
||||
is.NotNil(client) // Now returns a client even without auth for potential TLS configuration
|
||||
})
|
||||
|
||||
t.Run("should handle custom TLS configuration without authentication", func(t *testing.T) {
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io",
|
||||
Authentication: false,
|
||||
ManagementConfiguration: &portainer.RegistryManagementConfiguration{
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
// In a real scenario, these would point to actual cert files
|
||||
TLSCACertPath: "",
|
||||
TLSCertPath: "",
|
||||
TLSKeyPath: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
client, err := createOCIRegistryClient(registry)
|
||||
// Should succeed even without cert files when they're empty strings
|
||||
is.NoError(err)
|
||||
is.NotNil(client) // Should get a client configured for TLS
|
||||
})
|
||||
|
||||
t.Run("should return error for invalid credentials", func(t *testing.T) {
|
||||
@@ -209,7 +235,7 @@ func TestLoginToOCIRegistry(t *testing.T) {
|
||||
Authentication: true,
|
||||
Username: " ",
|
||||
}
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
client, err := createOCIRegistryClient(registry)
|
||||
is.Error(err)
|
||||
is.Nil(client)
|
||||
// The error might be a validation error or a login error, both are acceptable
|
||||
@@ -227,7 +253,7 @@ func TestLoginToOCIRegistry(t *testing.T) {
|
||||
}
|
||||
// this will fail because it can't connect to the registry,
|
||||
// but it proves that the loginToOCIRegistry function is calling the login function.
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
client, err := createOCIRegistryClient(registry)
|
||||
is.Error(err)
|
||||
is.Nil(client)
|
||||
is.Contains(err.Error(), "failed to login to registry")
|
||||
@@ -249,7 +275,7 @@ func TestLoginToOCIRegistry(t *testing.T) {
|
||||
}
|
||||
// this will fail because it can't connect to the registry,
|
||||
// but it proves that the loginToOCIRegistry function is calling the login function.
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
client, err := createOCIRegistryClient(registry)
|
||||
is.Error(err)
|
||||
is.Nil(client)
|
||||
is.Contains(err.Error(), "failed to login to registry")
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/registryhttp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
|
||||
@@ -16,6 +16,15 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
|
||||
log.Error().Err(err).Str("registryUrl", registry.URL).Msg("Failed to create registry client")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Configure HTTP client based on registry type using the shared utility
|
||||
httpClient, usePlainHTTP, err := registryhttp.CreateClient(®istry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registryClient.PlainHTTP = usePlainHTTP
|
||||
|
||||
// By default, oras sends multiple requests to get the full list of repos/tags/referrers.
|
||||
// set a high page size limit for fewer round trips.
|
||||
// e.g. https://github.com/oras-project/oras-go/blob/v2.6.0/registry/remote/registry.go#L129-L142
|
||||
@@ -29,7 +38,7 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
|
||||
strings.TrimSpace(registry.Password) != "" {
|
||||
|
||||
registryClient.Client = &auth.Client{
|
||||
Client: retry.DefaultClient,
|
||||
Client: httpClient,
|
||||
Cache: auth.NewCache(),
|
||||
Credential: auth.StaticCredential(registry.URL, auth.Credential{
|
||||
Username: registry.Username,
|
||||
@@ -43,8 +52,8 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
|
||||
Bool("authentication", true).
|
||||
Msg("Created ORAS registry client with authentication")
|
||||
} else {
|
||||
// Use default client for anonymous access
|
||||
registryClient.Client = retry.DefaultClient
|
||||
// Use the configured HTTP client for anonymous access
|
||||
registryClient.Client = httpClient
|
||||
|
||||
log.Debug().
|
||||
Str("registryURL", registry.URL).
|
||||
|
||||
@@ -138,9 +138,16 @@ func TestCreateClient_AuthenticationScenarios(t *testing.T) {
|
||||
assert.NotNil(t, authClient, "Auth client should not be nil")
|
||||
assert.NotNil(t, authClient.Credential, "Credential function should be set")
|
||||
} else {
|
||||
// Should use retry.DefaultClient (no authentication)
|
||||
assert.Equal(t, retry.DefaultClient, client.Client,
|
||||
"Expected retry.DefaultClient for anonymous access")
|
||||
// For anonymous access without custom TLS, all registries should use retry.DefaultClient
|
||||
// (Only registries with custom TLS configuration use a different retry client)
|
||||
if tt.registry.ManagementConfiguration == nil || !tt.registry.ManagementConfiguration.TLSConfig.TLS {
|
||||
assert.Equal(t, retry.DefaultClient, client.Client,
|
||||
"Expected retry.DefaultClient for anonymous access without custom TLS")
|
||||
} else {
|
||||
// Custom TLS configuration means a custom retry client
|
||||
assert.NotEqual(t, retry.DefaultClient, client.Client,
|
||||
"Expected custom retry client for registry with custom TLS")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
)
|
||||
|
||||
// Response structs for each function
|
||||
@@ -110,6 +112,8 @@ func TestProbeTelnetConnection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectProxy(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
|
||||
48
pkg/registryhttp/client.go
Normal file
48
pkg/registryhttp/client.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package registryhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/rs/zerolog/log"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
// CreateClient creates an HTTP client with appropriate TLS configuration based on registry type.
|
||||
// All registries use retry clients for better resilience.
|
||||
// Returns the HTTP client, whether to use plainHTTP, and any error.
|
||||
func CreateClient(registry *portainer.Registry) (*http.Client, bool, error) {
|
||||
switch registry.Type {
|
||||
case portainer.AzureRegistry, portainer.EcrRegistry, portainer.GithubRegistry, portainer.GitlabRegistry:
|
||||
// Cloud registries use the default retry client with built-in TLS
|
||||
return retry.DefaultClient, false, nil
|
||||
default:
|
||||
// For all other registry types, check if custom TLS is needed
|
||||
if registry.ManagementConfiguration != nil && registry.ManagementConfiguration.TLSConfig.TLS {
|
||||
// Need custom TLS configuration - create a retry client with custom transport
|
||||
baseTransport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(
|
||||
registry.ManagementConfiguration.TLSConfig,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create TLS configuration")
|
||||
return nil, false, err
|
||||
}
|
||||
baseTransport.TLSClientConfig = tlsConfig
|
||||
|
||||
// Create a retry transport wrapping our custom base transport
|
||||
retryTransport := retry.NewTransport(baseTransport)
|
||||
httpClient := &http.Client{
|
||||
Transport: retryTransport,
|
||||
}
|
||||
return httpClient, false, nil
|
||||
}
|
||||
|
||||
// Default to HTTP for non-cloud registries without TLS configuration
|
||||
return retry.DefaultClient, true, nil
|
||||
}
|
||||
}
|
||||
49
yarn.lock
49
yarn.lock
@@ -4808,6 +4808,11 @@
|
||||
"@sagold/json-pointer" "^5.1.2"
|
||||
ebnf "^1.9.1"
|
||||
|
||||
"@seznam/compose-react-refs@^1.0.6":
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@seznam/compose-react-refs/-/compose-react-refs-1.0.6.tgz#6ec4e70bdd6e32f8e70b4100f27267cf306bd8df"
|
||||
integrity sha512-izzOXQfeQLonzrIQb8u6LQ8dk+ymz3WXTIXjvOlTXHq6sbzROg3NWU+9TTAOpEoK9Bth24/6F/XrfHJ5yR5n6Q==
|
||||
|
||||
"@shikijs/core@1.29.2":
|
||||
version "1.29.2"
|
||||
resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-1.29.2.tgz#9c051d3ac99dd06ae46bd96536380c916e552bf3"
|
||||
@@ -6992,6 +6997,11 @@
|
||||
loupe "^3.1.2"
|
||||
tinyrainbow "^1.2.0"
|
||||
|
||||
"@vtaits/use-lazy-ref@^0.1.4":
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@vtaits/use-lazy-ref/-/use-lazy-ref-0.1.4.tgz#6befc141f4b29f97022259b00c4a5b6c482fe953"
|
||||
integrity sha512-pdHe8k2WLIm8ccVfNw3HzeTCkifKKjVQ3hpiM7/rMynCp8nev715wrY2RCYnbeowNvekWqpGdHtrWKfCDocC6g==
|
||||
|
||||
"@webassemblyjs/ast@1.11.5", "@webassemblyjs/ast@^1.11.5":
|
||||
version "1.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.5.tgz#6e818036b94548c1fb53b754b5cae3c9b208281c"
|
||||
@@ -12868,6 +12878,11 @@ klona@^2.0.6:
|
||||
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
|
||||
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
|
||||
|
||||
krustykrab@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/krustykrab/-/krustykrab-1.1.0.tgz#2b77faf06da9a43fe740799ac73fc2e8b6b515b0"
|
||||
integrity sha512-xpX9MPbw+nJseewe6who9Oq46RQwrBfps+dO/N4fSjJhsf2+y4XWC2kz46oBGX8yzMHyYJj35ug0X5s5yxB6tA==
|
||||
|
||||
kubernetes-types@^1.30.0:
|
||||
version "1.30.0"
|
||||
resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116"
|
||||
@@ -15514,6 +15529,18 @@ react-remove-scroll@^2.6.3:
|
||||
use-callback-ref "^1.3.3"
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-select-async-paginate@^0.7.11:
|
||||
version "0.7.11"
|
||||
resolved "https://registry.yarnpkg.com/react-select-async-paginate/-/react-select-async-paginate-0.7.11.tgz#737b3fef1beb23dab82c7d2b90059c7b823aae1d"
|
||||
integrity sha512-AjtCLPMk5DLNgygwQprEPC0gfVIjkou+QYvXM+2gm/LeRpY1Gv5KNT79EYB37H1uMCrwA+HL9BY7OtlaNWtYNg==
|
||||
dependencies:
|
||||
"@seznam/compose-react-refs" "^1.0.6"
|
||||
"@vtaits/use-lazy-ref" "^0.1.4"
|
||||
krustykrab "^1.1.0"
|
||||
sleep-promise "^9.1.0"
|
||||
use-is-mounted-ref "^1.5.0"
|
||||
use-latest "^1.3.0"
|
||||
|
||||
react-select@^5.2.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.2.1.tgz#416c25c6b79b94687702374e019c4f2ed9d159d6"
|
||||
@@ -16554,6 +16581,11 @@ slash@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
|
||||
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
|
||||
|
||||
sleep-promise@^9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/sleep-promise/-/sleep-promise-9.1.0.tgz#101ebe65700bcd184709da95d960967b02b79d03"
|
||||
integrity sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==
|
||||
|
||||
slice-ansi@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a"
|
||||
@@ -17908,6 +17940,23 @@ use-callback-ref@^1.3.3:
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-is-mounted-ref@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/use-is-mounted-ref/-/use-is-mounted-ref-1.5.0.tgz#d737e7b30f1bbbaca594f21cdd2621dc52ae8180"
|
||||
integrity sha512-p5FksHf/ospZUr5KU9ese6u3jp9fzvZ3wuSb50i0y6fdONaHWgmOqQtxR/PUcwi6hnhQDbNxWSg3eTK3N6m+dg==
|
||||
|
||||
use-isomorphic-layout-effect@^1.1.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz#2f11a525628f56424521c748feabc2ffcc962fce"
|
||||
integrity sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==
|
||||
|
||||
use-latest@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.3.0.tgz#549b9b0d4c1761862072f0899c6f096eb379137a"
|
||||
integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==
|
||||
dependencies:
|
||||
use-isomorphic-layout-effect "^1.1.1"
|
||||
|
||||
use-resize-observer@^9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c"
|
||||
|
||||
Reference in New Issue
Block a user