Compare commits

..

1 Commits

Author SHA1 Message Date
Steven Kang 730e05f40c security: cve-2025-30204 and other low ones - release 2.29 [BE-11781] (#640) 2025-04-16 10:09:18 +12:00
212 changed files with 1299 additions and 6656 deletions
-6
View File
@@ -94,14 +94,8 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.29.2'
- '2.29.1'
- '2.29.0'
- '2.28.1'
- '2.28.0'
- '2.27.6'
- '2.27.5'
- '2.27.4'
- '2.27.3'
- '2.27.2'
- '2.27.1'
-1
View File
@@ -12,7 +12,6 @@ linters:
- copyloopvar
- intrange
- perfsprint
- ineffassign
linters-settings:
depguard:
+50 -5
View File
@@ -2,6 +2,7 @@ package archive
import (
"archive/zip"
"bytes"
"fmt"
"io"
"os"
@@ -11,6 +12,50 @@ import (
"github.com/pkg/errors"
)
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
func UnzipArchive(archiveData []byte, dest string) error {
zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
if err != nil {
return err
}
for _, zipFile := range zipReader.File {
err := extractFileFromArchive(zipFile, dest)
if err != nil {
return err
}
}
return nil
}
func extractFileFromArchive(file *zip.File, dest string) error {
f, err := file.Open()
if err != nil {
return err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return err
}
fpath := filepath.Join(dest, file.Name)
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
if err != nil {
return err
}
_, err = io.Copy(outFile, bytes.NewReader(data))
if err != nil {
return err
}
return outFile.Close()
}
// UnzipFile will decompress a zip archive, moving all files and folders
// within the zip file (parameter 1) to an output directory (parameter 2).
func UnzipFile(src string, dest string) error {
@@ -31,11 +76,11 @@ func UnzipFile(src string, dest string) error {
if f.FileInfo().IsDir() {
// Make Folder
os.MkdirAll(p, os.ModePerm)
continue
}
if err := unzipFile(f, p); err != nil {
err = unzipFile(f, p)
if err != nil {
return err
}
}
@@ -48,20 +93,20 @@ func unzipFile(f *zip.File, p string) error {
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
}
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
}
defer outFile.Close()
rc, err := f.Open()
if err != nil {
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
}
defer rc.Close()
if _, err = io.Copy(outFile, rc); err != nil {
_, err = io.Copy(outFile, rc)
if err != nil {
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
}
+45
View File
@@ -0,0 +1,45 @@
package cli
import (
"strings"
portainer "github.com/portainer/portainer/api"
"gopkg.in/alecthomas/kingpin.v2"
)
type pairListBool []portainer.Pair
// Set implementation for a list of portainer.Pair
func (l *pairListBool) Set(value string) error {
p := new(portainer.Pair)
// default to true. example setting=true is equivalent to setting
parts := strings.SplitN(value, "=", 2)
if len(parts) != 2 {
p.Name = parts[0]
p.Value = "true"
} else {
p.Name = parts[0]
p.Value = parts[1]
}
*l = append(*l, *p)
return nil
}
// String implementation for a list of pair
func (l *pairListBool) String() string {
return ""
}
// IsCumulative implementation for a list of pair
func (l *pairListBool) IsCumulative() bool {
return true
}
func BoolPairs(s kingpin.Settings) (target *[]portainer.Pair) {
target = new([]portainer.Pair)
s.SetValue((*pairListBool)(target))
return
}
+4 -4
View File
@@ -1,4 +1,4 @@
package logs
package main
import (
"fmt"
@@ -10,7 +10,7 @@ import (
"github.com/rs/zerolog/pkgerrors"
)
func ConfigureLogger() {
func configureLogger() {
zerolog.ErrorStackFieldName = "stack_trace"
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
@@ -21,7 +21,7 @@ func ConfigureLogger() {
log.Logger = log.Logger.With().Caller().Stack().Logger()
}
func SetLoggingLevel(level string) {
func setLoggingLevel(level string) {
switch level {
case "ERROR":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
@@ -34,7 +34,7 @@ func SetLoggingLevel(level string) {
}
}
func SetLoggingMode(mode string) {
func setLoggingMode(mode string) {
switch mode {
case "PRETTY":
log.Logger = log.Output(zerolog.ConsoleWriter{
+7 -8
View File
@@ -39,7 +39,6 @@ import (
"github.com/portainer/portainer/api/kubernetes"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/ldap"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/oauth"
"github.com/portainer/portainer/api/pendingactions"
"github.com/portainer/portainer/api/pendingactions/actions"
@@ -167,8 +166,8 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
}
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
@@ -423,7 +422,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
}
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, *flags.Assets)
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
@@ -582,13 +581,13 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
func main() {
logs.ConfigureLogger()
logs.SetLoggingMode("PRETTY")
configureLogger()
setLoggingMode("PRETTY")
flags := initCLI()
logs.SetLoggingLevel(*flags.LogLevel)
logs.SetLoggingMode(*flags.LogMode)
setLoggingLevel(*flags.LogLevel)
setLoggingMode(*flags.LogMode)
for {
server := buildServer(flags)
+9 -17
View File
@@ -18,7 +18,8 @@ func (m *Migrator) updateResourceControlsToDBVersion22() error {
for _, resourceControl := range legacyResourceControls {
resourceControl.AdministratorsOnly = false
if err := m.resourceControlService.Update(resourceControl.ID, &resourceControl); err != nil {
err := m.resourceControlService.Update(resourceControl.ID, &resourceControl)
if err != nil {
return err
}
}
@@ -41,8 +42,8 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
for _, user := range legacyUsers {
user.PortainerAuthorizations = authorization.DefaultPortainerAuthorizations()
if err := m.userService.Update(user.ID, &user); err != nil {
err = m.userService.Update(user.ID, &user)
if err != nil {
return err
}
}
@@ -51,47 +52,38 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
if err != nil {
return err
}
endpointAdministratorRole.Priority = 1
endpointAdministratorRole.Authorizations = authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole()
if err := m.roleService.Update(endpointAdministratorRole.ID, endpointAdministratorRole); err != nil {
return err
}
err = m.roleService.Update(endpointAdministratorRole.ID, endpointAdministratorRole)
helpDeskRole, err := m.roleService.Read(portainer.RoleID(2))
if err != nil {
return err
}
helpDeskRole.Priority = 2
helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers)
if err := m.roleService.Update(helpDeskRole.ID, helpDeskRole); err != nil {
return err
}
err = m.roleService.Update(helpDeskRole.ID, helpDeskRole)
standardUserRole, err := m.roleService.Read(portainer.RoleID(3))
if err != nil {
return err
}
standardUserRole.Priority = 3
standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers)
if err := m.roleService.Update(standardUserRole.ID, standardUserRole); err != nil {
return err
}
err = m.roleService.Update(standardUserRole.ID, standardUserRole)
readOnlyUserRole, err := m.roleService.Read(portainer.RoleID(4))
if err != nil {
return err
}
readOnlyUserRole.Priority = 4
readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers)
if err := m.roleService.Update(readOnlyUserRole.ID, readOnlyUserRole); err != nil {
err = m.roleService.Update(readOnlyUserRole.ID, readOnlyUserRole)
if err != nil {
return err
}
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.30.0",
"KubectlShellImage": "portainer/kubectl-shell:2.29.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.30.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.29.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+15
View File
@@ -0,0 +1,15 @@
package validate
import (
"github.com/go-playground/validator/v10"
portainer "github.com/portainer/portainer/api"
)
var validate *validator.Validate
func ValidateLDAPSettings(ldp *portainer.LDAPSettings) error {
validate = validator.New()
registerValidationMethods(validate)
return validate.Struct(ldp)
}
+61
View File
@@ -0,0 +1,61 @@
package validate
import (
"testing"
portainer "github.com/portainer/portainer/api"
)
func TestValidateLDAPSettings(t *testing.T) {
tests := []struct {
name string
ldap portainer.LDAPSettings
wantErr bool
}{
{
name: "Empty LDAP Settings",
ldap: portainer.LDAPSettings{},
wantErr: true,
},
{
name: "With URL",
ldap: portainer.LDAPSettings{
AnonymousMode: true,
URL: "192.168.0.1:323",
},
wantErr: false,
},
{
name: "Validate URL and URLs",
ldap: portainer.LDAPSettings{
AnonymousMode: true,
URL: "192.168.0.1:323",
},
wantErr: false,
},
{
name: "validate client ldap",
ldap: portainer.LDAPSettings{
AnonymousMode: false,
ReaderDN: "CN=LDAP API Service Account",
Password: "Qu**dfUUU**",
URL: "aukdc15.pgc.co:389",
TLSConfig: portainer.TLSConfiguration{
TLS: false,
TLSSkipVerify: false,
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateLDAPSettings(&tt.ldap)
if (err == nil) == tt.wantErr {
t.Errorf("No error expected but got %s", err)
}
})
}
}
@@ -0,0 +1,17 @@
package validate
import (
"github.com/go-playground/validator/v10"
)
func registerValidationMethods(v *validator.Validate) {
v.RegisterValidation("validate_bool", ValidateBool)
}
/**
* Validation methods below are being used for custom validation
*/
func ValidateBool(fl validator.FieldLevel) bool {
_, ok := fl.Field().Interface().(bool)
return ok
}
+13
View File
@@ -73,6 +73,19 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
)
}
func CreateClientFromEnv() (*client.Client, error) {
return client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
}
func CreateSimpleClient() (*client.Client, error) {
return client.NewClientWithOpts(
client.WithAPIVersionNegotiation(),
)
}
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint, timeout)
if err != nil {
+6 -5
View File
@@ -38,10 +38,10 @@ func NewClientWithRegistry(registryClient *RegistryClient, clientFactory *docker
func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
ctx, cancel := c.timeoutContext()
defer cancel()
// Docker references with both a tag and digest are currently not supported
if image.Tag != "" && image.Digest != "" {
if err := image.TrimDigest(); err != nil {
err := image.trimDigest()
if err != nil {
return "", err
}
}
@@ -69,7 +69,7 @@ func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
// Retrieve remote digest through HEAD request
rmDigest, err := docker.GetDigest(ctx, sysCtx, rmRef)
if err != nil {
// Fallback to public registry for hub
// fallback to public registry for hub
if image.HubLink != "" {
rmDigest, err = docker.GetDigest(ctx, c.sysCtx, rmRef)
if err == nil {
@@ -131,7 +131,8 @@ func ParseRepoDigests(repoDigests []string) []digest.Digest {
func ParseRepoTags(repoTags []string) []*Image {
images := make([]*Image, 0)
for _, repoTag := range repoTags {
if image := ParseRepoTag(repoTag); image != nil {
image := ParseRepoTag(repoTag)
if image != nil {
images = append(images, image)
}
}
@@ -146,7 +147,7 @@ func ParseRepoDigest(repoDigest string) digest.Digest {
d, err := digest.Parse(strings.Split(repoDigest, "@")[1])
if err != nil {
log.Warn().Err(err).Str("digest", repoDigest).Msg("skip invalid repo item")
log.Warn().Msgf("Skip invalid repo digest item: %s [error: %v]", repoDigest, err)
return ""
}
+7 -14
View File
@@ -26,7 +26,7 @@ type Image struct {
Digest digest.Digest
HubLink string
named reference.Named
Opts ParseImageOptions `json:"-"`
opts ParseImageOptions
}
// ParseImageOptions holds image options for parsing.
@@ -43,10 +43,9 @@ func (i *Image) Name() string {
// FullName return the real full name may include Tag or Digest of the image, Tag first.
func (i *Image) FullName() string {
if i.Tag == "" {
return i.Name() + "@" + i.Digest.String()
return fmt.Sprintf("%s@%s", i.Name(), i.Digest)
}
return i.Name() + ":" + i.Tag
return fmt.Sprintf("%s:%s", i.Name(), i.Tag)
}
// String returns the string representation of an image, including Tag and Digest if existed.
@@ -67,25 +66,22 @@ func (i *Image) Reference() string {
func (i *Image) WithDigest(digest digest.Digest) (err error) {
i.Digest = digest
i.named, err = reference.WithDigest(i.named, digest)
return err
}
func (i *Image) WithTag(tag string) (err error) {
i.Tag = tag
i.named, err = reference.WithTag(i.named, tag)
return err
}
func (i *Image) TrimDigest() error {
func (i *Image) trimDigest() error {
i.Digest = ""
named, err := ParseImage(ParseImageOptions{Name: i.FullName()})
if err != nil {
return err
}
i.named = &named
return nil
}
@@ -96,12 +92,11 @@ func ParseImage(parseOpts ParseImageOptions) (Image, error) {
if err != nil {
return Image{}, errors.Wrapf(err, "parsing image %s failed", parseOpts.Name)
}
// Add the latest lag if they did not provide one.
named = reference.TagNameOnly(named)
i := Image{
Opts: parseOpts,
opts: parseOpts,
named: named,
Domain: reference.Domain(named),
Path: reference.Path(named),
@@ -127,16 +122,15 @@ func ParseImage(parseOpts ParseImageOptions) (Image, error) {
}
func (i *Image) hubLink() (string, error) {
if i.Opts.HubTpl != "" {
if i.opts.HubTpl != "" {
var out bytes.Buffer
tmpl, err := template.New("tmpl").
Option("missingkey=error").
Parse(i.Opts.HubTpl)
Parse(i.opts.HubTpl)
if err != nil {
return "", err
}
err = tmpl.Execute(&out, i)
return out.String(), err
}
@@ -148,7 +142,6 @@ func (i *Image) hubLink() (string, error) {
prefix = "_"
path = strings.Replace(i.Path, "library/", "", 1)
}
return "https://hub.docker.com/" + prefix + "/" + path, nil
case "docker.bintray.io", "jfrog-docker-reg2.bintray.io":
return "https://bintray.com/jfrog/reg2/" + strings.ReplaceAll(i.Path, "/", "%3A"), nil
+7 -7
View File
@@ -16,7 +16,7 @@ func TestImageParser(t *testing.T) {
})
is.NoError(err, "")
is.Equal("docker.io/portainer/portainer-ee:latest", image.FullName())
is.Equal("portainer/portainer-ee", image.Opts.Name)
is.Equal("portainer/portainer-ee", image.opts.Name)
is.Equal("latest", image.Tag)
is.Equal("portainer/portainer-ee", image.Path)
is.Equal("docker.io", image.Domain)
@@ -32,7 +32,7 @@ func TestImageParser(t *testing.T) {
})
is.NoError(err, "")
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
is.Equal("", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -49,7 +49,7 @@ func TestImageParser(t *testing.T) {
})
is.NoError(err, "")
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
is.Equal("v0.0.30", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -71,7 +71,7 @@ func TestUpdateParsedImage(t *testing.T) {
is.NoError(err, "")
_ = image.WithTag("v0.0.31")
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
is.Equal("v0.0.31", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -89,7 +89,7 @@ func TestUpdateParsedImage(t *testing.T) {
is.NoError(err, "")
_ = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
is.Equal("v0.0.30", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -105,9 +105,9 @@ func TestUpdateParsedImage(t *testing.T) {
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
})
is.NoError(err, "")
_ = image.TrimDigest()
_ = image.trimDigest()
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
is.Equal("v0.0.30", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
+2 -2
View File
@@ -29,7 +29,7 @@ func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) {
return "", "", err
}
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
registry, err := findBestMatchRegistry(image.opts.Name, registries)
if err != nil {
return "", "", err
}
@@ -59,7 +59,7 @@ func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) {
return "", err
}
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
registry, err := findBestMatchRegistry(image.opts.Name, registries)
if err != nil {
return "", err
}
-12
View File
@@ -12,15 +12,3 @@ type kubernetesMockDeployer struct {
func NewKubernetesDeployer() *kubernetesMockDeployer {
return &kubernetesMockDeployer{}
}
func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil
}
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil
}
func (deployer *kubernetesMockDeployer) Restart(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil
}
+45 -36
View File
@@ -1,8 +1,13 @@
package exec
import (
"context"
"bytes"
"fmt"
"os"
"os/exec"
"path"
"runtime"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -10,17 +15,13 @@ import (
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/pkg/libkubectl"
"github.com/pkg/errors"
)
const (
defaultServerURL = "https://kubernetes.default.svc"
)
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment(endpoint).
type KubernetesDeployer struct {
binaryPath string
dataStore dataservices.DataStore
reverseTunnelService portainer.ReverseTunnelService
signatureService portainer.DigitalSignatureService
@@ -30,8 +31,9 @@ type KubernetesDeployer struct {
}
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) *KubernetesDeployer {
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, binaryPath string) *KubernetesDeployer {
return &KubernetesDeployer{
binaryPath: binaryPath,
dataStore: datastore,
reverseTunnelService: reverseTunnelService,
signatureService: signatureService,
@@ -76,56 +78,63 @@ func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *
}
// Deploy upserts Kubernetes resources defined in manifest(s)
func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
return deployer.command("apply", userID, endpoint, resources, namespace)
func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return deployer.command("apply", userID, endpoint, manifestFiles, namespace)
}
// Remove deletes Kubernetes resources defined in manifest(s)
func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
return deployer.command("delete", userID, endpoint, resources, namespace)
func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return deployer.command("delete", userID, endpoint, manifestFiles, namespace)
}
func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
token, err := deployer.getToken(userID, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment)
if err != nil {
return "", errors.Wrap(err, "failed generating a user token")
}
serverURL := defaultServerURL
command := path.Join(deployer.binaryPath, "kubectl")
if runtime.GOOS == "windows" {
command = path.Join(deployer.binaryPath, "kubectl.exe")
}
args := []string{"--token", token}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
url, proxy, err := deployer.getAgentURL(endpoint)
if err != nil {
return "", errors.WithMessage(err, "failed generating endpoint URL")
}
defer proxy.Close()
serverURL = url
args = append(args, "--server", url)
args = append(args, "--insecure-skip-tls-verify")
}
client, err := libkubectl.NewClient(&libkubectl.ClientAccess{
Token: token,
ServerUrl: serverURL,
}, namespace, "", true)
if operation == "delete" {
args = append(args, "--ignore-not-found=true")
}
args = append(args, operation)
for _, path := range manifestFiles {
args = append(args, "-f", strings.TrimSpace(path))
}
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "POD_NAMESPACE=default")
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
return "", errors.Wrap(err, "failed to create kubectl client")
return "", errors.Wrapf(err, "failed to execute kubectl command: %q", stderr.String())
}
operations := map[string]func(context.Context, []string) (string, error){
"apply": client.Apply,
"delete": client.Delete,
}
operationFunc, ok := operations[operation]
if !ok {
return "", errors.Errorf("unsupported operation: %s", operation)
}
output, err := operationFunc(context.Background(), resources)
if err != nil {
return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation)
}
return output, nil
return string(output), nil
}
func (deployer *KubernetesDeployer) getAgentURL(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
-173
View File
@@ -1,173 +0,0 @@
package exec
import (
"context"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
type mockKubectlClient struct {
applyFunc func(ctx context.Context, files []string) error
deleteFunc func(ctx context.Context, files []string) error
rolloutRestartFunc func(ctx context.Context, resources []string) error
}
func (m *mockKubectlClient) Apply(ctx context.Context, files []string) error {
if m.applyFunc != nil {
return m.applyFunc(ctx, files)
}
return nil
}
func (m *mockKubectlClient) Delete(ctx context.Context, files []string) error {
if m.deleteFunc != nil {
return m.deleteFunc(ctx, files)
}
return nil
}
func (m *mockKubectlClient) RolloutRestart(ctx context.Context, resources []string) error {
if m.rolloutRestartFunc != nil {
return m.rolloutRestartFunc(ctx, resources)
}
return nil
}
func testExecuteKubectlOperation(client *mockKubectlClient, operation string, manifestFiles []string) error {
operations := map[string]func(context.Context, []string) error{
"apply": client.Apply,
"delete": client.Delete,
"rollout-restart": client.RolloutRestart,
}
operationFunc, ok := operations[operation]
if !ok {
return fmt.Errorf("unsupported operation: %s", operation)
}
if err := operationFunc(context.Background(), manifestFiles); err != nil {
return fmt.Errorf("failed to execute kubectl %s command: %w", operation, err)
}
return nil
}
func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
called := false
mockClient := &mockKubectlClient{
applyFunc: func(ctx context.Context, files []string) error {
called = true
assert.Equal(t, []string{"manifest1.yaml", "manifest2.yaml"}, files)
return nil
},
}
manifests := []string{"manifest1.yaml", "manifest2.yaml"}
err := testExecuteKubectlOperation(mockClient, "apply", manifests)
assert.NoError(t, err)
assert.True(t, called)
}
func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
expectedErr := errors.New("kubectl apply failed")
called := false
mockClient := &mockKubectlClient{
applyFunc: func(ctx context.Context, files []string) error {
called = true
assert.Equal(t, []string{"error.yaml"}, files)
return expectedErr
},
}
manifests := []string{"error.yaml"}
err := testExecuteKubectlOperation(mockClient, "apply", manifests)
assert.Error(t, err)
assert.Contains(t, err.Error(), expectedErr.Error())
assert.True(t, called)
}
func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
called := false
mockClient := &mockKubectlClient{
deleteFunc: func(ctx context.Context, files []string) error {
called = true
assert.Equal(t, []string{"manifest1.yaml"}, files)
return nil
},
}
manifests := []string{"manifest1.yaml"}
err := testExecuteKubectlOperation(mockClient, "delete", manifests)
assert.NoError(t, err)
assert.True(t, called)
}
func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
expectedErr := errors.New("kubectl delete failed")
called := false
mockClient := &mockKubectlClient{
deleteFunc: func(ctx context.Context, files []string) error {
called = true
assert.Equal(t, []string{"error.yaml"}, files)
return expectedErr
},
}
manifests := []string{"error.yaml"}
err := testExecuteKubectlOperation(mockClient, "delete", manifests)
assert.Error(t, err)
assert.Contains(t, err.Error(), expectedErr.Error())
assert.True(t, called)
}
func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
called := false
mockClient := &mockKubectlClient{
rolloutRestartFunc: func(ctx context.Context, resources []string) error {
called = true
assert.Equal(t, []string{"deployment/nginx"}, resources)
return nil
},
}
resources := []string{"deployment/nginx"}
err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
assert.NoError(t, err)
assert.True(t, called)
}
func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
expectedErr := errors.New("kubectl rollout restart failed")
called := false
mockClient := &mockKubectlClient{
rolloutRestartFunc: func(ctx context.Context, resources []string) error {
called = true
assert.Equal(t, []string{"deployment/error"}, resources)
return expectedErr
},
}
resources := []string{"deployment/error"}
err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
assert.Error(t, err)
assert.Contains(t, err.Error(), expectedErr.Error())
assert.True(t, called)
}
func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) {
mockClient := &mockKubectlClient{}
err := testExecuteKubectlOperation(mockClient, "unsupported", []string{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported operation")
}
+2 -2
View File
@@ -3,9 +3,9 @@ package update
import (
"time"
"github.com/asaskevich/govalidator"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/validate"
)
func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error {
@@ -17,7 +17,7 @@ func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error
return httperrors.NewInvalidPayloadError("Webhook or Interval must be provided")
}
if autoUpdate.Webhook != "" && !validate.IsUUID(autoUpdate.Webhook) {
if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) {
return httperrors.NewInvalidPayloadError("invalid Webhook format")
}
+4 -2
View File
@@ -1,17 +1,19 @@
package git
import (
"github.com/asaskevich/govalidator"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/validate"
)
func ValidateRepoConfig(repoConfig *gittypes.RepoConfig) error {
if len(repoConfig.URL) == 0 || !validate.IsURL(repoConfig.URL) {
if len(repoConfig.URL) == 0 || !govalidator.IsURL(repoConfig.URL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
return ValidateRepoAuthentication(repoConfig.Authentication)
}
func ValidateRepoAuthentication(auth *gittypes.GitAuthentication) error {
@@ -16,8 +16,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -228,7 +228,7 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
if len(payload.Description) == 0 {
return errors.New("Invalid custom template description")
}
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
@@ -15,7 +15,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
)
type customTemplateUpdatePayload struct {
@@ -169,7 +170,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.EdgeTemplate = payload.EdgeTemplate
if payload.RepositoryURL != "" {
if !validate.IsURL(payload.RepositoryURL) {
if !govalidator.IsURL(payload.RepositoryURL) {
return httperror.BadRequest("Invalid repository URL. Must correspond to a valid URL format", err)
}
+4 -3
View File
@@ -15,7 +15,8 @@ import (
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
)
type edgeJobBasePayload struct {
@@ -52,7 +53,7 @@ func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) er
return errors.New("invalid Edge job name")
}
if !validate.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) {
if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) {
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
@@ -135,7 +136,7 @@ func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error {
return errors.New("invalid Edge job name")
}
if !validate.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
payload.Name = name
+3 -2
View File
@@ -14,7 +14,8 @@ import (
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
)
type edgeJobUpdatePayload struct {
@@ -27,7 +28,7 @@ type edgeJobUpdatePayload struct {
}
func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error {
if payload.Name != nil && !validate.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
@@ -11,8 +11,8 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
)
@@ -59,7 +59,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
@@ -9,7 +9,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
)
type fileResponse struct {
@@ -28,7 +29,7 @@ type repositoryFilePreviewPayload struct {
}
func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error {
if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) {
if len(payload.Repository) == 0 || !govalidator.IsURL(payload.Repository) {
return errors.New("invalid repository URL. Must correspond to a valid URL format")
}
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.30.0
// @version 2.29.0
// @description.markdown api-description.md
// @termsOfService
-4
View File
@@ -62,10 +62,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm/{release}/history",
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
// `helm rollback [RELEASE_NAME] [REVISION]`
h.Handle("/{id}/kubernetes/helm/{release}/rollback",
httperror.LoggerHandler(h.helmRollback)).Methods(http.MethodPost)
return h
}
+1 -5
View File
@@ -26,8 +26,6 @@ type installChartPayload struct {
Chart string `json:"chart"`
Repo string `json:"repo"`
Values string `json:"values"`
Version string `json:"version"`
Atomic bool `json:"atomic"`
}
var errChartNameInvalid = errors.New("invalid chart name. " +
@@ -103,10 +101,8 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
installOpts := options.InstallOptions{
Name: p.Name,
Chart: p.Chart,
Version: p.Version,
Namespace: p.Namespace,
Repo: p.Repo,
Atomic: p.Atomic,
KubernetesClusterAccess: clusterAccess,
}
@@ -196,7 +192,7 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte,
g := new(errgroup.Group)
for _, resource := range yamlResources {
g.Go(func() error {
tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
tmpfile, err := os.CreateTemp("", "helm-manifest-*")
if err != nil {
return errors.Wrap(err, "failed to create a tmp helm manifest file")
}
-105
View File
@@ -1,105 +0,0 @@
package helm
import (
"net/http"
"time"
"github.com/portainer/portainer/pkg/libhelm/options"
_ "github.com/portainer/portainer/pkg/libhelm/release"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id HelmRollback
// @summary Rollback a helm release
// @description Rollback a helm release to a previous revision
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param release path string true "Helm release name"
// @param namespace query string false "specify an optional namespace"
// @param revision query int false "specify the revision to rollback to (defaults to previous revision if not specified)"
// @param wait query boolean false "wait for resources to be ready (default: false)"
// @param waitForJobs query boolean false "wait for jobs to complete before marking the release as successful (default: false)"
// @param recreate query boolean false "performs pods restart for the resource if applicable (default: true)"
// @param force query boolean false "force resource update through delete/recreate if needed (default: false)"
// @param timeout query int false "time to wait for any individual Kubernetes operation in seconds (default: 300)"
// @success 200 {object} release.Release "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or release name."
// @failure 500 "Server error occurred while attempting to rollback the release."
// @router /endpoints/{id}/kubernetes/helm/{release}/rollback [post]
func (handler *Handler) helmRollback(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
release, err := request.RetrieveRouteVariableValue(r, "release")
if err != nil {
return httperror.BadRequest("No release specified", err)
}
clusterAccess, httperr := handler.getHelmClusterAccess(r)
if httperr != nil {
return httperr
}
// build the rollback options
rollbackOpts := options.RollbackOptions{
KubernetesClusterAccess: clusterAccess,
Name: release,
// Set default values
Recreate: true, // Default to recreate pods (restart)
Timeout: 5 * time.Minute, // Default timeout of 5 minutes
}
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
// optional namespace. The library defaults to "default"
if namespace != "" {
rollbackOpts.Namespace = namespace
}
revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
// optional revision. If not specified, it will rollback to the previous revision
if revision > 0 {
rollbackOpts.Version = revision
}
// Default for wait is false, only set to true if explicitly requested
wait, err := request.RetrieveBooleanQueryParameter(r, "wait", true)
if err == nil {
rollbackOpts.Wait = wait
}
// Default for waitForJobs is false, only set to true if explicitly requested
waitForJobs, err := request.RetrieveBooleanQueryParameter(r, "waitForJobs", true)
if err == nil {
rollbackOpts.WaitForJobs = waitForJobs
}
// Default for recreate is true (set above), override if specified
recreate, err := request.RetrieveBooleanQueryParameter(r, "recreate", true)
if err == nil {
rollbackOpts.Recreate = recreate
}
// Default for force is false, only set to true if explicitly requested
force, err := request.RetrieveBooleanQueryParameter(r, "force", true)
if err == nil {
rollbackOpts.Force = force
}
timeout, _ := request.RetrieveNumericQueryParameter(r, "timeout", true)
// Override default timeout if specified
if timeout > 0 {
rollbackOpts.Timeout = time.Duration(timeout) * time.Second
}
releaseInfo, err := handler.helmPackageManager.Rollback(rollbackOpts)
if err != nil {
return httperror.InternalServerError("Failed to rollback helm release", err)
}
return response.JSON(w, releaseInfo)
}
@@ -36,14 +36,11 @@ func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string,
// Restore the original body for further use
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
return "", httperror.InternalServerError("Unable to read request body", err)
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
payload := models.K8sNamespaceDetails{}
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err)
}
namespaceName := payload.Name
+4 -4
View File
@@ -14,8 +14,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)
@@ -62,15 +62,15 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)")
}
if payload.LogoURL != nil && *payload.LogoURL != "" && !validate.IsURL(*payload.LogoURL) {
if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) {
return errors.New("Invalid logo URL. Must correspond to a valid URL format")
}
if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !validate.IsURL(*payload.TemplatesURL) {
if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) {
return errors.New("Invalid external templates URL. Must correspond to a valid URL format")
}
if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !validate.IsURL(*payload.HelmRepositoryURL) {
if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !govalidator.IsURL(*payload.HelmRepositoryURL) {
return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format")
}
@@ -14,8 +14,8 @@ import (
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
@@ -205,7 +205,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if len(payload.Name) == 0 {
return errors.New("Invalid stack name")
}
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
@@ -15,8 +15,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
)
@@ -96,7 +96,7 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
}
func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
@@ -112,7 +112,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
}
func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error {
if len(payload.ManifestURL) == 0 || !validate.IsURL(payload.ManifestURL) {
if len(payload.ManifestURL) == 0 || !govalidator.IsURL(payload.ManifestURL) {
return errors.New("Invalid manifest URL")
}
@@ -11,8 +11,8 @@ import (
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
valid "github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
)
@@ -142,7 +142,7 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
if len(payload.SwarmID) == 0 {
return errors.New("Invalid Swarm ID")
}
if len(payload.RepositoryURL) == 0 || !valid.IsURL(payload.RepositoryURL) {
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
@@ -11,7 +11,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
)
type userAccessTokenCreatePayload struct {
@@ -23,10 +24,10 @@ func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
if len(payload.Description) == 0 {
return errors.New("invalid description: cannot be empty")
}
if validate.HasWhitespaceOnly(payload.Description) {
if govalidator.HasWhitespaceOnly(payload.Description) {
return errors.New("invalid description: cannot contain only whitespaces")
}
if validate.MinStringLength(payload.Description, 128) {
if govalidator.MinStringLength(payload.Description, "128") {
return errors.New("invalid description: cannot be longer than 128 characters")
}
return nil
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
)
@@ -38,7 +38,7 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
if err != nil {
return httperror.BadRequest("Invalid query parameter: id", err)
}
if !validate.IsHexadecimal(attachID) {
if !govalidator.IsHexadecimal(attachID) {
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
}
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/segmentio/encoding/json"
)
@@ -42,7 +42,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
if err != nil {
return httperror.BadRequest("Invalid query parameter: id", err)
}
if !validate.IsHexadecimal(execID) {
if !govalidator.IsHexadecimal(execID) {
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
}
@@ -1,36 +0,0 @@
package middlewares
import (
"net/http"
"slices"
"github.com/gorilla/csrf"
)
var (
// Idempotent (safe) methods as defined by RFC7231 section 4.2.2.
safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"}
)
type plainTextHTTPRequestHandler struct {
next http.Handler
}
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if slices.Contains(safeMethods, r.Method) {
h.next.ServeHTTP(w, r)
return
}
req := r
// If original request was HTTPS (via proxy), keep CSRF checks.
if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
req = csrf.PlaintextHTTPRequest(r)
}
h.next.ServeHTTP(w, req)
}
func PlaintextHTTPRequest(next http.Handler) http.Handler {
return &plainTextHTTPRequestHandler{next: next}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func WithItem[TId ~int, TObject any](getter ItemGetter[TId, TObject], idParam st
}
}
func FetchItem[T any](request *http.Request, contextKey ItemContextKey) (*T, error) {
func FetchItem[T any](request *http.Request, contextKey string) (*T, error) {
contextData := request.Context().Value(contextKey)
if contextData == nil {
return nil, errors.New("unable to find item in request context")
+3 -2
View File
@@ -52,7 +52,7 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
endpointURL.Scheme = "https"
}
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = agent.NewTransport(factory.signatureService, httpTransport)
@@ -63,7 +63,8 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
Port: 0,
}
if err := proxyServer.start(); err != nil {
err = proxyServer.start()
if err != nil {
return nil, errors.Wrap(err, "failed starting proxy server")
}
+1 -1
View File
@@ -15,7 +15,7 @@ func newAzureProxy(endpoint *portainer.Endpoint, dataStore dataservices.DataStor
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, dataStore, endpoint)
return proxy, nil
}
+1 -1
View File
@@ -72,7 +72,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = dockerTransport
return proxy, nil
}
+1 -1
View File
@@ -13,7 +13,7 @@ func newGitlabProxy(uri string) (http.Handler, error) {
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(url)
proxy := newSingleHostReverseProxyWithHostHeader(url)
proxy.Transport = gitlab.NewTransport()
return proxy, nil
}
+3 -3
View File
@@ -43,7 +43,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = transport
return proxy, nil
@@ -73,7 +73,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
}
endpointURL.Scheme = "http"
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory)
return proxy, nil
@@ -104,7 +104,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
return proxy, nil
+3 -11
View File
@@ -10,14 +10,9 @@ import (
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
return &httputil.ReverseProxy{Director: createDirector(target)}
}
func createDirector(target *url.URL) func(*http.Request) {
sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"}
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
return func(req *http.Request) {
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
@@ -31,11 +26,8 @@ func createDirector(target *url.URL) func(*http.Request) {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
for _, header := range sensitiveHeaders {
delete(req.Header, header)
}
}
return &httputil.ReverseProxy{Director: director}
}
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
@@ -1,116 +0,0 @@
package factory
import (
"net/http"
"net/url"
"testing"
"github.com/google/go-cmp/cmp"
)
func Test_createDirector(t *testing.T) {
testCases := []struct {
name string
target *url.URL
req *http.Request
expectedReq *http.Request
}{
{
name: "base case",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
),
},
{
name: "no User-Agent",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
),
},
{
name: "Sensitive Headers",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Accept-Encoding": "gzip",
"Accept": "application/json",
"User-Agent": "something",
"Cookie": "junk",
"X-Csrf-Token": "junk",
},
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
director := createDirector(tc.target)
director(tc.req)
if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
t.Fatalf("requests are different: \n%s", diff)
}
})
}
}
func createURL(t *testing.T, urlString string) *url.URL {
parsedURL, err := url.Parse(urlString)
if err != nil {
t.Fatalf("Failed to create url: %s", err)
}
return parsedURL
}
func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request {
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("Failed to create http request: %s", err)
} else {
for k, v := range headers {
req.Header.Add(k, v)
}
}
return req
}
func compareRequests(a, b *http.Request) bool {
methodEqual := a.Method == b.Method
urlEqual := cmp.Diff(a.URL, b.URL) == ""
hostEqual := a.Host == b.Host
protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor
headersEqual := cmp.Diff(a.Header, b.Header) == ""
return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual
}
-3
View File
@@ -344,7 +344,6 @@ func Test_apiKeyLookup(t *testing.T) {
req.Header.Add("x-api-key", rawAPIKey)
token, err := bouncer.apiKeyLookup(req)
require.NoError(t, err)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@@ -359,7 +358,6 @@ func Test_apiKeyLookup(t *testing.T) {
req.Header.Add("x-api-key", rawAPIKey)
token, err := bouncer.apiKeyLookup(req)
require.NoError(t, err)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@@ -374,7 +372,6 @@ func Test_apiKeyLookup(t *testing.T) {
req.Header.Add("x-api-key", rawAPIKey)
token, err := bouncer.apiKeyLookup(req)
require.NoError(t, err)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
+1 -1
View File
@@ -349,7 +349,7 @@ func (server *Server) Start() error {
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
httpServer := &http.Server{
Addr: server.BindAddress,
Handler: middlewares.PlaintextHTTPRequest(handler),
Handler: handler,
ErrorLog: errorLogger,
}
+7
View File
@@ -47,6 +47,13 @@ type (
}
)
func NewKubeClientFromClientset(cli *kubernetes.Clientset) *KubeClient {
return &KubeClient{
cli: cli,
instanceID: "",
}
}
// NewClientFactory returns a new instance of a ClientFactory
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*ClientFactory, error) {
if userSessionTimeout == "" {
+1 -1
View File
@@ -1638,7 +1638,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.30.0"
APIVersion = "2.29.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
+5
View File
@@ -45,6 +45,11 @@ func SanitizeLabel(value string) string {
return strings.Trim(onlyAllowedCharacterString, ".-_")
}
// IsGitStack checks if the stack is a git stack or not
func IsGitStack(stack *portainer.Stack) bool {
return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0
}
// IsRelativePathStack checks if the stack is a git stack or not
func IsRelativePathStack(stack *portainer.Stack) bool {
// Always return false in CE
+1 -5
View File
@@ -32,10 +32,6 @@ body {
color: var(--text-body-color) !important;
}
.bg-widget-color {
background: var(--bg-widget-color);
}
html,
body,
#page-wrapper,
@@ -228,7 +224,7 @@ input[type='checkbox'] {
.blocklist-item--selected {
background-color: var(--bg-blocklist-item-selected-color);
border-color: var(--border-blocklist-item-selected-color);
border: 2px solid var(--border-blocklist-item-selected-color);
color: var(--text-blocklist-item-selected-color);
}
+3 -1
View File
@@ -20,7 +20,9 @@
}
.vertical-center {
@apply inline-flex items-center gap-1;
display: inline-flex;
align-items: center;
gap: 5px;
}
.flex-center {
+2 -2
View File
@@ -268,7 +268,7 @@
--bg-body-color: var(--grey-2);
--bg-btn-default-color: var(--grey-3);
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
--bg-blocklist-item-selected-color: var(--ui-gray-iron-10);
--bg-blocklist-item-selected-color: var(--grey-3);
--bg-card-color: var(--grey-1);
--bg-checkbox-border-color: var(--grey-8);
--bg-code-color: var(--grey-2);
@@ -388,7 +388,7 @@
--border-navtabs-color: var(--grey-38);
--border-pre-color: var(--grey-3);
--border-blocklist: var(--ui-gray-9);
--border-blocklist-item-selected-color: var(--grey-31);
--border-blocklist-item-selected-color: var(--grey-38);
--border-pagination-span-color: var(--grey-1);
--border-pagination-hover-color: var(--grey-3);
--border-panel-color: var(--grey-2);
@@ -18,7 +18,7 @@
<div class="col-sm-12" ng-if="ctrl.formValues.displayCodeEditor">
<web-editor-form
identifier="config-creation-editor"
text-tip="Define or paste the content of your config here"
placeholder="Define or paste the content of your config here"
yml="false"
on-change="(ctrl.editorUpdate)"
value="ctrl.formValues.ConfigContent"
@@ -94,7 +94,7 @@
<div class="col-sm-12">
<code-editor
identifier="image-build-editor"
text-tip="Define or paste the content of your Dockerfile here"
placeholder="Define or paste the content of your Dockerfile here"
docker-file="true"
on-change="(editorUpdate)"
></code-editor>
+1 -1
View File
@@ -145,7 +145,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const helmApplication = {
name: 'kubernetes.helm',
url: '/helm/:namespace/:name?revision&tab',
url: '/helm/:namespace/:name',
views: {
'content@': {
component: 'kubernetesHelmApplicationView',
@@ -165,7 +165,7 @@
value="$ctrl.formValues.DataYaml"
on-change="($ctrl.editorUpdate)"
yml="true"
text-tip="Define or paste key-value pairs, one pair per line"
placeholder="Define or paste key-value pairs, one pair per line"
>
</web-editor-form>
</div>
@@ -1,6 +1,6 @@
<react-code-editor
id="$ctrl.identifier"
text-tip="$ctrl.textTip"
placeholder="$ctrl.placeholder"
type="$ctrl.type"
readonly="$ctrl.readOnly"
on-change="($ctrl.handleChange)"
@@ -5,7 +5,7 @@ angular.module('portainer.app').component('codeEditor', {
controller,
bindings: {
identifier: '@',
textTip: '@',
placeholder: '@',
yml: '<',
dockerFile: '<',
shell: '<',
@@ -6,7 +6,7 @@ export const webEditorForm = {
bindings: {
identifier: '@',
textTip: '@',
placeholder: '@',
yml: '<',
value: '<',
readOnly: '<',
@@ -42,7 +42,7 @@
<div class="col-sm-12 col-lg-12">
<code-editor
identifier="{{ $ctrl.identifier }}"
text-tip="{{ $ctrl.textTip }}"
placeholder="{{ $ctrl.placeholder }}"
read-only="$ctrl.readOnly"
yml="$ctrl.yml"
value="$ctrl.value"
+1 -3
View File
@@ -223,7 +223,7 @@ export const ngModule = angular
'reactCodeEditor',
r2a(CodeEditor, [
'id',
'textTip',
'placeholder',
'type',
'readonly',
'onChange',
@@ -233,8 +233,6 @@ export const ngModule = angular
'versions',
'onVersionChange',
'schema',
'fileName',
'placeholder',
])
)
.component(
@@ -128,7 +128,7 @@
on-change="(onChangeFileContent)"
ng-required="true"
yml="true"
text-tip="Define or paste the content of your docker compose file here"
placeholder="Define or paste the content of your docker compose file here"
read-only="state.isEditorReadOnly"
schema="dockerComposeSchema"
>
+1 -1
View File
@@ -160,7 +160,7 @@
<code-editor
read-only="orphaned"
identifier="stack-editor"
text-tip="Define or paste the content of your docker compose file here"
placeholder="Define or paste the content of your docker compose file here"
yml="true"
on-change="(editorUpdate)"
value="stackFileContent"
-16
View File
@@ -1,16 +0,0 @@
/**
* Format a date to a human-readable string based on the user's locale.
*/
export function localizeDate(date: Date) {
return date
.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
.replace('am', 'AM')
.replace('pm', 'PM');
}
+15 -21
View File
@@ -6,36 +6,36 @@ import { Icon } from '@@/Icon';
type AlertType = 'success' | 'error' | 'info' | 'warn';
export const alertSettings: Record<
const alertSettings: Record<
AlertType,
{ container: string; header: string; body: string; icon: ReactNode }
> = {
success: {
container:
'border-green-4 bg-green-2 th-dark:bg-green-10 th-dark:border-green-8 th-highcontrast:bg-green-10 th-highcontrast:border-green-8',
header: 'text-green-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-green-7 th-dark:text-white th-highcontrast:text-white',
'border-green-4 bg-green-2 th-dark:bg-green-3 th-dark:border-green-5',
header: 'text-green-8',
body: 'text-green-7',
icon: CheckCircle,
},
error: {
container:
'border-error-4 bg-error-2 th-dark:bg-error-10 th-dark:border-error-8 th-highcontrast:bg-error-10 th-highcontrast:border-error-8',
header: 'text-error-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-error-7 th-dark:text-white th-highcontrast:text-white',
'border-error-4 bg-error-2 th-dark:bg-error-3 th-dark:border-error-5',
header: 'text-error-8',
body: 'text-error-7',
icon: XCircle,
},
info: {
container:
'border-blue-4 bg-blue-2 th-dark:bg-blue-10 th-dark:border-blue-8 th-highcontrast:bg-blue-10 th-highcontrast:border-blue-8',
header: 'text-blue-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-blue-7 th-dark:text-white th-highcontrast:text-white',
'border-blue-4 bg-blue-2 th-dark:bg-blue-3 th-dark:border-blue-5',
header: 'text-blue-8',
body: 'text-blue-7',
icon: AlertCircle,
},
warn: {
container:
'border-warning-4 bg-warning-2 th-dark:bg-warning-10 th-dark:border-warning-8 th-highcontrast:bg-warning-10 th-highcontrast:border-warning-8',
header: 'text-warning-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-warning-7 th-dark:text-white th-highcontrast:text-white',
'border-warning-4 bg-warning-2 th-dark:bg-warning-3 th-dark:border-warning-5',
header: 'text-warning-8',
body: 'text-warning-7',
icon: AlertTriangle,
},
};
@@ -76,18 +76,12 @@ export function Alert({
);
}
export function AlertContainer({
function AlertContainer({
className,
children,
}: PropsWithChildren<{ className?: string }>) {
return (
<div
className={clsx(
'border rounded-lg border-solid [&_ul]:ps-8',
'p-3',
className
)}
>
<div className={clsx('rounded-md border-2 border-solid', 'p-3', className)}>
{children}
</div>
);
@@ -18,7 +18,6 @@ export default {
'dangerSecondary',
'warnSecondary',
'infoSecondary',
'muted',
],
},
},
@@ -36,7 +35,6 @@ function Template({ type = 'success' }: Props) {
dangerSecondary: 'dangerSecondary badge',
warnSecondary: 'warnSecondary badge',
infoSecondary: 'infoSecondary badge',
muted: 'muted badge',
};
return <Badge type={type}>{message[type]}</Badge>;
}
+1 -7
View File
@@ -9,8 +9,7 @@ export type BadgeType =
| 'successSecondary'
| 'dangerSecondary'
| 'warnSecondary'
| 'infoSecondary'
| 'muted';
| 'infoSecondary';
// the classes are typed in full because tailwind doesn't render the interpolated classes
const typeClasses: Record<BadgeType, string> = {
@@ -55,11 +54,6 @@ const typeClasses: Record<BadgeType, string> = {
'th-dark:text-blue-3 th-dark:bg-blue-9',
'th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9'
),
muted: clsx(
'text-gray-9 bg-gray-3',
'th-dark:text-gray-3 th-dark:bg-gray-9',
'th-highcontrast:text-gray-3 th-highcontrast:bg-gray-9'
),
};
export interface Props {
@@ -1,75 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { localizeDate } from '@/react/common/date-utils';
import { Badge } from '@@/Badge';
import { BlocklistItem } from './BlocklistItem';
const meta: Meta<typeof BlocklistItem> = {
title: 'Components/Blocklist/BlocklistItem',
component: BlocklistItem,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="blocklist">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof BlocklistItem>;
export const Default: Story = {
args: {
children: 'Default Blocklist Item',
},
};
export const Selected: Story = {
args: {
children: 'Selected Blocklist Item',
isSelected: true,
},
};
export const AsDiv: Story = {
args: {
children: 'Blocklist Item as div',
as: 'div',
},
};
export const WithCustomContent: Story = {
args: {
children: (
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-wrap gap-1 justify-between">
<Badge type="success">Deployed</Badge>
<span className="text-xs text-muted">Revision #4</span>
</div>
<div className="flex flex-wrap gap-1 justify-between">
<span className="text-xs text-muted">my-app-1.0.0</span>
<span className="text-xs text-muted">
{localizeDate(new Date('2000-01-01'))}
</span>
</div>
</div>
),
},
};
export const MultipleItems: Story = {
render: () => (
<div className="blocklist">
<BlocklistItem>First Item</BlocklistItem>
<BlocklistItem isSelected>Second Item (Selected)</BlocklistItem>
<BlocklistItem>Third Item</BlocklistItem>
</div>
),
};
+1 -1
View File
@@ -10,7 +10,7 @@ export function Card({ className, children }: PropsWithChildren<Props>) {
<div
className={clsx(
className,
'rounded-lg border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
'rounded border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
)}
>
{children}
@@ -47,33 +47,17 @@
.root :global(.cm-editor .cm-gutters) {
border-right: 0px;
@apply bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black;
}
.root :global(.cm-merge-b) {
@apply border-0 border-l border-solid border-l-gray-5 th-dark:border-l-gray-7 th-highcontrast:border-l-gray-2;
}
.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) {
text-align: left;
}
.codeEditor :global(.cm-editor),
.codeEditor :global(.cm-editor .cm-scroller) {
.root :global(.cm-editor),
.root :global(.cm-editor .cm-scroller) {
border-radius: 8px;
}
/* code mirror merge side-by-side editor */
.root :global(.cm-merge-a),
.root :global(.cm-merge-a .cm-scroller) {
@apply !rounded-r-none;
}
.root :global(.cm-merge-b),
.root :global(.cm-merge-b .cm-scroller) {
@apply !rounded-l-none;
}
/* Search Panel */
/* Ideally we would use a react component for that, but this is the easy solution for onw */
@@ -178,29 +162,3 @@
.root :global(.cm-activeLineGutter) {
@apply bg-inherit;
}
/* Collapsed lines gutter styles for all themes */
.root :global(.cm-editor .cm-collapsedLines) {
/* inherit bg, instead of using styles from library */
background: inherit;
@apply bg-blue-2 th-dark:bg-blue-10 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
}
.root :global(.cm-editor .cm-collapsedLines):hover {
@apply bg-blue-3 th-dark:bg-blue-9 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
}
.root :global(.cm-editor .cm-collapsedLines:before) {
content: '↧ Expand all';
background: var(--bg-tooltip-color);
color: var(--text-tooltip-color);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
margin-left: 4px;
}
/* override the default content */
.root :global(.cm-editor .cm-collapsedLines:after) {
content: '';
}
@@ -1,21 +1,9 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Extension } from '@codemirror/state';
import { CodeEditor } from './CodeEditor';
const mockExtension: Extension = { extension: [] };
vi.mock('yaml-schema', () => ({
// yamlSchema has 5 return values (all extensions)
yamlSchema: () => [
mockExtension,
mockExtension,
mockExtension,
mockExtension,
mockExtension,
],
yamlCompletion: () => () => ({}),
}));
vi.mock('yaml-schema', () => ({}));
const defaultProps = {
id: 'test-editor',
@@ -36,7 +24,7 @@ test('should render with basic props', () => {
test('should display placeholder when provided', async () => {
const placeholder = 'Enter your code here';
const { findByText } = render(
<CodeEditor {...defaultProps} textTip={placeholder} />
<CodeEditor {...defaultProps} placeholder={placeholder} />
);
const placeholderText = await findByText(placeholder);
@@ -56,7 +44,7 @@ test('should show copy button and copy content', async () => {
clipboard: mockClipboard,
});
const copyButton = await findByText('Copy');
const copyButton = await findByText('Copy to clipboard');
expect(copyButton).toBeVisible();
await userEvent.click(copyButton);
@@ -125,14 +113,3 @@ test('should apply custom height', async () => {
const editor = (await findByRole('textbox')).parentElement?.parentElement;
expect(editor).toHaveStyle({ height: customHeight });
});
test('should render with file name header when provided', async () => {
const fileName = 'example.yaml';
const testValue = 'file content';
const { findByText } = render(
<CodeEditor {...defaultProps} fileName={fileName} value={testValue} />
);
expect(await findByText(fileName)).toBeInTheDocument();
expect(await findByText(testValue)).toBeInTheDocument();
});
+225
View File
@@ -0,0 +1,225 @@
import CodeMirror, {
keymap,
oneDarkHighlightStyle,
} from '@uiw/react-codemirror';
import {
StreamLanguage,
LanguageSupport,
syntaxHighlighting,
indentService,
} from '@codemirror/language';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { useCallback, useMemo, useState } from 'react';
import { createTheme } from '@uiw/codemirror-themes';
import { tags as highlightTags } from '@lezer/highlight';
import type { JSONSchema7 } from 'json-schema';
import { lintKeymap, lintGutter } from '@codemirror/lint';
import { defaultKeymap } from '@codemirror/commands';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { yamlCompletion, yamlSchema } from 'yaml-schema';
import { AutomationTestingProps } from '@/types';
import { CopyButton } from '@@/buttons/CopyButton';
import { useDebounce } from '../hooks/useDebounce';
import styles from './CodeEditor.module.css';
import { TextTip } from './Tip/TextTip';
import { StackVersionSelector } from './StackVersionSelector';
type Type = 'yaml' | 'shell' | 'dockerfile';
interface Props extends AutomationTestingProps {
id: string;
placeholder?: string;
type?: Type;
readonly?: boolean;
onChange?: (value: string) => void;
value: string;
height?: string;
versions?: number[];
onVersionChange?: (version: number) => void;
schema?: JSONSchema7;
}
const theme = createTheme({
theme: 'light',
settings: {
background: 'var(--bg-codemirror-color)',
foreground: 'var(--text-codemirror-color)',
caret: 'var(--border-codemirror-cursor-color)',
selection: 'var(--bg-codemirror-selected-color)',
selectionMatch: 'var(--bg-codemirror-selected-color)',
gutterBackground: 'var(--bg-codemirror-gutters-color)',
},
styles: [
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
{
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
color: 'var(--text-cm-string-color)',
},
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
{
tag: highlightTags.variableName,
color: 'var(--text-cm-variable-name-color)',
},
],
});
// Custom indentation service for YAML
const yamlIndentExtension = indentService.of((context, pos) => {
const prevLine = context.lineAt(pos, -1);
// Default to same as previous line
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
// If previous line ends with a colon, increase indent
if (/:\s*$/.test(prevLine.text)) {
return prevIndent + 2; // Indent 2 spaces after a colon
}
return prevIndent;
});
// Create enhanced YAML language with custom indentation (from @codemirror/legacy-modes/mode/yaml)
const yamlLanguageLegacy = new LanguageSupport(StreamLanguage.define(yaml), [
yamlIndentExtension,
syntaxHighlighting(oneDarkHighlightStyle),
]);
const dockerFileLanguage = new LanguageSupport(
StreamLanguage.define(dockerFile)
);
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
const docTypeExtensionMap: Record<Type, LanguageSupport> = {
yaml: yamlLanguageLegacy,
dockerfile: dockerFileLanguage,
shell: shellLanguage,
};
function schemaValidationExtensions(schema: JSONSchema7) {
// skip the hover extension because fields like 'networks' display as 'null' with no description when using the default hover
// skip the completion extension in favor of custom completion
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
return [
yaml,
linter,
autocompletion({
icons: false,
activateOnTypingDelay: 300,
selectOnOpen: true,
activateOnTyping: true,
override: [
(ctx) => {
const getCompletions = yamlCompletion();
const completions = getCompletions(ctx);
if (Array.isArray(completions)) {
return null;
}
return completions;
},
],
}),
stateExtensions,
yamlIndentExtension,
syntaxHighlighting(oneDarkHighlightStyle),
lintGutter(),
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
];
}
export function CodeEditor({
id,
onChange = () => {},
placeholder,
readonly,
value,
versions,
onVersionChange,
height = '500px',
type,
schema,
'data-cy': dataCy,
}: Props) {
const [isRollback, setIsRollback] = useState(false);
const extensions = useMemo(() => {
if (!type || !docTypeExtensionMap[type]) {
return [];
}
// YAML-specific schema validation
if (schema && type === 'yaml') {
return schemaValidationExtensions(schema);
}
// Default language support
return [docTypeExtensionMap[type]];
}, [type, schema]);
const handleVersionChange = useCallback(
(version: number) => {
if (versions && versions.length > 1) {
setIsRollback(version < versions[0]);
}
onVersionChange?.(version);
},
[onVersionChange, versions]
);
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
return (
<>
<div className="mb-2 flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center">
{!!placeholder && <TextTip color="blue">{placeholder}</TextTip>}
</div>
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
<CopyButton
data-cy={`copy-code-button-${id}`}
fadeDelay={2500}
copyText={value}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy to clipboard
</CopyButton>
</div>
</div>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
<CodeMirror
className={styles.root}
theme={theme}
value={debouncedValue}
onChange={debouncedOnChange}
readOnly={readonly || isRollback}
id={id}
extensions={extensions}
height={height}
basicSetup={{
highlightSelectionMatches: false,
autocompletion: !!schema,
}}
data-cy={dataCy}
/>
</>
);
}
@@ -1,158 +0,0 @@
import CodeMirror from '@uiw/react-codemirror';
import { useCallback, useState } from 'react';
import { createTheme } from '@uiw/codemirror-themes';
import { tags as highlightTags } from '@lezer/highlight';
import type { JSONSchema7 } from 'json-schema';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { CopyButton } from '@@/buttons/CopyButton';
import { useDebounce } from '../../hooks/useDebounce';
import { TextTip } from '../Tip/TextTip';
import { StackVersionSelector } from '../StackVersionSelector';
import styles from './CodeEditor.module.css';
import {
useCodeEditorExtensions,
CodeEditorType,
} from './useCodeEditorExtensions';
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
interface Props extends AutomationTestingProps {
id: string;
textTip?: string;
type?: CodeEditorType;
readonly?: boolean;
onChange?: (value: string) => void;
value: string;
height?: string;
versions?: number[];
onVersionChange?: (version: number) => void;
schema?: JSONSchema7;
fileName?: string;
placeholder?: string;
}
export const theme = createTheme({
theme: 'light',
settings: {
background: 'var(--bg-codemirror-color)',
foreground: 'var(--text-codemirror-color)',
caret: 'var(--border-codemirror-cursor-color)',
selection: 'var(--bg-codemirror-selected-color)',
selectionMatch: 'var(--bg-codemirror-selected-color)',
},
styles: [
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
{
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
color: 'var(--text-cm-string-color)',
},
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
{
tag: highlightTags.variableName,
color: 'var(--text-cm-variable-name-color)',
},
],
});
export function CodeEditor({
id,
onChange = () => {},
textTip,
readonly,
value,
versions,
onVersionChange,
height = '500px',
type,
schema,
'data-cy': dataCy,
fileName,
placeholder,
}: Props) {
const [isRollback, setIsRollback] = useState(false);
const extensions = useCodeEditorExtensions(type, schema);
const handleVersionChange = useCallback(
(version: number) => {
if (versions && versions.length > 1) {
setIsRollback(version < versions[0]);
}
onVersionChange?.(version);
},
[onVersionChange, versions]
);
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
return (
<>
<div className="mb-2 flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center">
{!!textTip && <TextTip color="blue">{textTip}</TextTip>}
</div>
{/* the copy button is in the file name header, when fileName is provided */}
{!fileName && (
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
<CopyButton
data-cy={`copy-code-button-${id}`}
fadeDelay={2500}
copyText={value}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
</div>
)}
</div>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
<div className="overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2">
{fileName && (
<FileNameHeaderRow>
<FileNameHeader
fileName={fileName}
copyText={value}
data-cy={`copy-code-button-${id}`}
/>
</FileNameHeaderRow>
)}
<CodeMirror
className={clsx(styles.root, styles.codeEditor)}
theme={theme}
value={debouncedValue}
onChange={debouncedOnChange}
readOnly={readonly || isRollback}
id={id}
extensions={extensions}
height={height}
basicSetup={{
highlightSelectionMatches: false,
autocompletion: !!schema,
}}
data-cy={dataCy}
placeholder={placeholder}
/>
</div>
</>
);
}
@@ -1,101 +0,0 @@
import { render } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { DiffViewer } from './DiffViewer';
// Mock CodeMirror
vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: () => <div data-cy="mock-editor" />,
oneDarkHighlightStyle: {},
keymap: {
of: () => ({}),
},
}));
// Mock react-codemirror-merge
vi.mock('react-codemirror-merge', () => {
function CodeMirrorMerge({ children }: { children: React.ReactNode }) {
return <div data-cy="mock-code-mirror-merge">{children}</div>;
}
function Original({ value }: { value: string }) {
return <div data-cy="mock-original">{value}</div>;
}
function Modified({ value }: { value: string }) {
return <div data-cy="mock-modified">{value}</div>;
}
CodeMirrorMerge.Original = Original;
CodeMirrorMerge.Modified = Modified;
return {
__esModule: true,
default: CodeMirrorMerge,
CodeMirrorMerge,
};
});
describe('DiffViewer', () => {
beforeEach(() => {
// Clear any mocks or state before each test
vi.clearAllMocks();
});
it('should render with basic props', () => {
const { getByText } = render(
<DiffViewer
originalCode="Original text"
newCode="New text"
id="test-diff-viewer"
data-cy="test-diff-viewer"
/>
);
// Check if the component renders with the expected content
expect(getByText('Original text')).toBeInTheDocument();
expect(getByText('New text')).toBeInTheDocument();
});
it('should render with file name headers when provided', () => {
const { getByText } = render(
<DiffViewer
originalCode="Original text"
newCode="New text"
id="test-diff-viewer"
data-cy="test-diff-viewer"
fileNames={{
original: 'Original File',
modified: 'Modified File',
}}
/>
);
// Look for elements with the expected class structure
const headerOriginal = getByText('Original File');
const headerModified = getByText('Modified File');
expect(headerOriginal).toBeInTheDocument();
expect(headerModified).toBeInTheDocument();
});
it('should apply custom height when provided', () => {
const customHeight = '800px';
const { container } = render(
<DiffViewer
originalCode="Original text"
newCode="New text"
id="test-diff-viewer"
data-cy="test-diff-viewer"
height={customHeight}
/>
);
// Find the element with the style containing the height
const divWithStyle = container.querySelector('[style*="height"]');
expect(divWithStyle).toBeInTheDocument();
// Check that the style contains the expected height
expect(divWithStyle?.getAttribute('style')).toContain(
`height: ${customHeight}`
);
});
});
@@ -1,138 +0,0 @@
import CodeMirrorMerge from 'react-codemirror-merge';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
import styles from './CodeEditor.module.css';
import {
CodeEditorType,
useCodeEditorExtensions,
} from './useCodeEditorExtensions';
import { theme } from './CodeEditor';
const { Original } = CodeMirrorMerge;
const { Modified } = CodeMirrorMerge;
type Props = {
originalCode: string;
newCode: string;
id: string;
type?: CodeEditorType;
placeholder?: string;
height?: string;
fileNames?: {
original: string;
modified: string;
};
className?: string;
} & AutomationTestingProps;
const defaultCollapseUnchanged = {
margin: 10,
minSize: 10,
};
export function DiffViewer({
originalCode,
newCode,
id,
'data-cy': dataCy,
type,
placeholder = 'No values found',
height = '500px',
fileNames,
className,
}: Props) {
const extensions = useCodeEditorExtensions(type);
const hasFileNames = !!fileNames?.original && !!fileNames?.modified;
return (
<div
className={clsx(
'overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2',
className
)}
>
{hasFileNames && (
<DiffFileNameHeaders
originalCopyText={originalCode}
modifiedCopyText={newCode}
originalFileName={fileNames.original}
modifiedFileName={fileNames.modified}
/>
)}
{/* additional div, so that the scroll gutter doesn't overlap with the rounded border, and always show scrollbar, so that the file name headers align */}
<div
style={
{
// tailwind doesn't like dynamic class names, so use a custom css variable for the height
// https://v3.tailwindcss.com/docs/content-configuration#dynamic-class-names
'--editor-min-height': height,
height,
} as React.CSSProperties
}
className="h-full [scrollbar-gutter:stable] overflow-y-scroll"
>
<CodeMirrorMerge
theme={theme}
className={clsx(
styles.root,
// to give similar sizing to CodeEditor
'[&_.cm-content]:!min-h-[var(--editor-min-height)] [&_.cm-gutters]:!min-h-[var(--editor-min-height)] [&_.cm-editor>.cm-scroller]:!min-h-[var(--editor-min-height)]'
)}
id={id}
data-cy={dataCy}
collapseUnchanged={defaultCollapseUnchanged}
>
<Original
value={originalCode}
extensions={extensions}
readOnly
editable={false}
placeholder={placeholder}
/>
<Modified
value={newCode}
extensions={extensions}
readOnly
editable={false}
placeholder={placeholder}
/>
</CodeMirrorMerge>
</div>
</div>
);
}
function DiffFileNameHeaders({
originalCopyText,
modifiedCopyText,
originalFileName,
modifiedFileName,
}: {
originalCopyText: string;
modifiedCopyText: string;
originalFileName: string;
modifiedFileName: string;
}) {
return (
<FileNameHeaderRow>
<div className="w-1/2">
<FileNameHeader
fileName={originalFileName}
copyText={originalCopyText}
data-cy="original"
/>
</div>
<div className="w-px bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-gray-2" />
<div className="flex-1">
<FileNameHeader
fileName={modifiedFileName}
copyText={modifiedCopyText}
data-cy="modified"
/>
</div>
</FileNameHeaderRow>
);
}
@@ -1,71 +0,0 @@
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { CopyButton } from '@@/buttons/CopyButton';
type FileNameHeaderProps = {
fileName: string;
copyText: string;
className?: string;
style?: React.CSSProperties;
} & AutomationTestingProps;
/**
* FileNameHeaderRow: Outer container for file name headers (single or multiple columns).
* Use this to wrap one or more <FileNameHeader> components (and optional dividers).
*/
export function FileNameHeaderRow({
children,
className,
style,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
return (
<div
className={clsx(
'flex w-full text-sm text-muted border-0 border-b border-solid border-b-gray-5 th-dark:border-b-gray-7 th-highcontrast:border-b-gray-2 bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black [scrollbar-gutter:stable] overflow-auto',
className
)}
style={style}
>
{children}
</div>
);
}
/**
* FileNameHeader: Renders a file name with a copy button, styled for use above a code editor or diff viewer.
* Should be used inside FileNameHeaderRow.
*/
export function FileNameHeader({
fileName,
copyText,
className = '',
style,
'data-cy': dataCy,
}: FileNameHeaderProps) {
return (
<div
className={clsx(
'w-full overflow-auto flex justify-between items-center gap-x-2 px-4 py-1 text-sm text-muted',
className
)}
style={style}
>
{fileName}
<CopyButton
data-cy={dataCy}
copyText={copyText}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
</div>
);
}
-1
View File
@@ -1 +0,0 @@
export * from './CodeEditor';
@@ -1,91 +0,0 @@
import { useMemo } from 'react';
import {
StreamLanguage,
LanguageSupport,
syntaxHighlighting,
indentService,
} from '@codemirror/language';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import {
oneDarkHighlightStyle,
keymap,
Extension,
} from '@uiw/react-codemirror';
import type { JSONSchema7 } from 'json-schema';
import { lintKeymap, lintGutter } from '@codemirror/lint';
import { defaultKeymap } from '@codemirror/commands';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { yamlCompletion, yamlSchema } from 'yaml-schema';
import { compact } from 'lodash';
import { lineNumbers } from '@codemirror/view';
export type CodeEditorType = 'yaml' | 'shell' | 'dockerfile';
// Custom indentation service for YAML
const yamlIndentExtension = indentService.of((context, pos) => {
const prevLine = context.lineAt(pos, -1);
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
if (/:\s*$/.test(prevLine.text)) {
return prevIndent + 2;
}
return prevIndent;
});
const dockerFileLanguage = new LanguageSupport(
StreamLanguage.define(dockerFile)
);
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
function yamlLanguage(schema?: JSONSchema7) {
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
return compact([
yaml,
linter,
stateExtensions,
yamlIndentExtension,
syntaxHighlighting(oneDarkHighlightStyle),
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
lineNumbers(),
lintGutter(),
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
// only show completions when a schema is provided
!!schema &&
autocompletion({
icons: false,
activateOnTypingDelay: 300,
selectOnOpen: true,
activateOnTyping: true,
override: [
(ctx) => {
const getCompletions = yamlCompletion();
const completions = getCompletions(ctx);
if (Array.isArray(completions)) {
return null;
}
completions.validFor = /^\w*$/;
return completions;
},
],
}),
]);
}
export function useCodeEditorExtensions(
type?: CodeEditorType,
schema?: JSONSchema7
): Extension[] {
return useMemo(() => {
switch (type) {
case 'dockerfile':
return [dockerFileLanguage];
case 'shell':
return [shellLanguage];
case 'yaml':
return yamlLanguage(schema);
default:
return [];
}
}, [type, schema]);
}
@@ -13,21 +13,21 @@ export type Props = {
};
const sizeStyles: Record<Size, string> = {
xs: 'text-xs gap-1',
sm: 'text-sm gap-2',
md: 'text-md gap-2',
xs: 'text-xs',
sm: 'text-sm',
md: 'text-md',
};
export function InlineLoader({ children, className, size = 'sm' }: Props) {
return (
<div
className={clsx(
'text-muted flex items-center',
'text-muted flex items-center gap-2',
className,
sizeStyles[size]
)}
>
<Icon icon={Loader2} className="animate-spin-slow flex-none" />
<Icon icon={Loader2} className="animate-spin-slow" />
{children}
</div>
);
+4 -19
View File
@@ -1,19 +1,10 @@
import { ReactNode } from 'react';
// allow custom labels
export interface RadioGroupOption<TValue> {
value: TValue;
label: ReactNode;
disabled?: boolean;
}
import { Option } from '@@/form-components/PortainerSelect';
interface Props<T extends string | number> {
options: Array<RadioGroupOption<T>> | ReadonlyArray<RadioGroupOption<T>>;
options: Array<Option<T>> | ReadonlyArray<Option<T>>;
selectedOption: T;
name: string;
onOptionChange: (value: T) => void;
groupClassName?: string;
itemClassName?: string;
}
export function RadioGroup<T extends string | number = string>({
@@ -21,18 +12,13 @@ export function RadioGroup<T extends string | number = string>({
selectedOption,
name,
onOptionChange,
groupClassName,
itemClassName,
}: Props<T>) {
return (
<div className={groupClassName ?? 'flex flex-wrap gap-x-2 gap-y-1'}>
<div className="flex flex-wrap gap-x-2 gap-y-1">
{options.map((option) => (
<label
key={option.value}
className={
itemClassName ??
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal'
}
className="col-sm-3 col-lg-2 control-label !p-0 text-left font-normal"
>
<input
type="radio"
@@ -42,7 +28,6 @@ export function RadioGroup<T extends string | number = string>({
onChange={() => onOptionChange(option.value)}
style={{ margin: '0 4px 0 0' }}
data-cy={`radio-${option.value}`}
disabled={option.disabled}
/>
{option.label}
</label>
-159
View File
@@ -1,159 +0,0 @@
import {
ComponentPropsWithoutRef,
forwardRef,
ElementRef,
PropsWithChildren,
} from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import clsx from 'clsx';
import { RefreshCw, X } from 'lucide-react';
import { Button } from './buttons';
// modified from shadcn sheet component
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetDescription = SheetPrimitive.Description;
type SheetTitleProps = {
title: string;
onReload?(): Promise<void> | void;
};
// similar to the PageHeader component with simplified props and no breadcrumbs
function SheetHeader({
onReload,
title,
children,
}: PropsWithChildren<SheetTitleProps>) {
return (
<div className="row">
<div className="col-sm-12 pt-3 flex gap-2 justify-between">
<div className="flex items-center gap-2">
<SheetPrimitive.DialogTitle className="m-0 text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
{title}
</SheetPrimitive.DialogTitle>
{onReload ? (
<Button
color="none"
size="large"
onClick={onReload}
className="m-0 p-0 focus:text-inherit"
title="Refresh drawer content"
data-cy="sheet-refreshButton"
>
<RefreshCw className="icon" />
</Button>
) : null}
</div>
{children}
</div>
</div>
);
}
const SheetOverlay = forwardRef<
ElementRef<typeof SheetPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={clsx(
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-[70vw] lg:w-[50vw] border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left max-w-2xl',
right:
'inset-y-0 right-0 h-full w-[70vw] lg:w-[50vw] border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right max-w-2xl',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
showCloseButton?: boolean;
}
const SheetContent = forwardRef<
ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(
(
{
side = 'right',
className,
children,
title,
showCloseButton = true,
...props
},
ref
) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={clsx(sheetVariants({ side }), className)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
{title ? <SheetHeader title={title} /> : null}
{children}
{showCloseButton && (
<SheetPrimitive.Close
asChild
className="absolute close-button right-9 top-8 disabled:pointer-events-none"
>
<Button
icon={X}
color="none"
className="btn-only-icon"
size="medium"
data-cy="sheet-closeButton"
>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
};
-2
View File
@@ -74,7 +74,6 @@ export function WebEditorForm({
children,
error,
schema,
textTip,
...props
}: PropsWithChildren<Props>) {
return (
@@ -100,7 +99,6 @@ export function WebEditorForm({
id={id}
type="yaml"
schema={schema as JSONSchema7}
textTip={textTip}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
@@ -1,11 +0,0 @@
import { ReactNode } from 'react';
import { Icon } from '@@/Icon';
export function WidgetIcon({ icon }: { icon: ReactNode }) {
return (
<div className="text-lg inline-flex items-center rounded-full bg-blue-3 text-blue-8 th-dark:bg-gray-9 th-dark:text-blue-3 p-2">
<Icon icon={icon} />
</div>
);
}
+9 -3
View File
@@ -1,7 +1,9 @@
import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
import { WidgetIcon } from './WidgetIcon';
import { Icon } from '@/react/components/Icon';
import { useWidgetContext } from './Widget';
interface Props {
title: ReactNode;
@@ -15,12 +17,16 @@ export function WidgetTitle({
className,
children,
}: PropsWithChildren<Props>) {
useWidgetContext();
return (
<div className="widget-header">
<div className="row">
<span className={clsx('pull-left vertical-center', className)}>
<WidgetIcon icon={icon} />
<h2 className="text-base m-0 ml-1">{title}</h2>
<div className="widget-icon">
<Icon icon={icon} className="space-right" />
</div>
<h2 className="text-base m-0">{title}</h2>
</span>
<span className={clsx('pull-right', className)}>{children}</span>
</div>
@@ -26,13 +26,13 @@ function Template({
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
children: 'Copy',
children: 'Copy to clipboard',
copyText: 'this will be copied to clipboard',
};
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
NoCopyText.args = {
children: 'Copy without copied text',
children: 'Copy to clipboard without copied text',
copyText: 'clipboard override',
displayText: '',
};
@@ -9,9 +9,10 @@ import {
import { Datatable, defaultGlobalFilterFn, Props } from './Datatable';
import {
BasicTableSettings,
createPersistedStore,
refreshableSettings,
TableSettingsWithRefreshable,
RefreshableTableSettings,
} from './types';
import { useTableState } from './useTableState';
@@ -29,14 +30,13 @@ const mockColumns = [
];
// mock table settings / state
export interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings {}
function createStore(storageKey: string) {
return createPersistedStore<TableSettingsWithRefreshable>(
storageKey,
'name',
(set) => ({
...refreshableSettings(set),
})
);
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
...refreshableSettings(set),
}));
}
const storageKey = 'test-table';
const settingsStore = createStore(storageKey);
+3 -29
View File
@@ -58,7 +58,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
getRowId?(row: D): string;
isRowSelectable?(row: Row<D>): boolean;
emptyContentLabel?: string;
title?: React.ReactNode;
title?: string;
titleIcon?: IconProps['icon'];
titleId?: string;
initialTableState?: Partial<TableState>;
@@ -71,8 +71,6 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
noWidget?: boolean;
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
includeSearch?: boolean;
ariaLabel?: string;
id?: string;
}
export function Datatable<D extends DefaultType>({
@@ -102,8 +100,6 @@ export function Datatable<D extends DefaultType>({
isServerSidePagination = false,
extendTableOptions = (value) => value,
includeSearch,
ariaLabel,
id,
}: Props<D> & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
@@ -185,14 +181,9 @@ export function Datatable<D extends DefaultType>({
() => _.difference(selectedItems, filteredItems),
[selectedItems, filteredItems]
);
const { titleAriaLabel, contentAriaLabel } = getAriaLabels(
ariaLabel,
title,
titleId
);
return (
<Table.Container noWidget={noWidget} aria-label={titleAriaLabel} id={id}>
<Table.Container noWidget={noWidget} aria-label={title}>
<DatatableHeader
onSearchChange={handleSearchBarChange}
searchValue={settings.search}
@@ -213,7 +204,7 @@ export function Datatable<D extends DefaultType>({
isLoading={isLoading}
onSortChange={handleSortChange}
data-cy={dataCy}
aria-label={contentAriaLabel}
aria-label={`${title} table`}
/>
<DatatableFooter
@@ -248,23 +239,6 @@ export function Datatable<D extends DefaultType>({
}
}
function getAriaLabels(
titleAriaLabel?: string,
title?: ReactNode,
titleId?: string
) {
if (titleAriaLabel) {
return { titleAriaLabel, contentAriaLabel: `${titleAriaLabel} table` };
}
if (typeof title === 'string') {
return { titleAriaLabel: title, contentAriaLabel: `${title} table` };
}
if (titleId) {
return { titleAriaLabel: titleId, contentAriaLabel: `${titleId} table` };
}
return { titleAriaLabel: 'table', contentAriaLabel: 'table' };
}
function defaultRenderRow<D extends DefaultType>(
row: Row<D>,
highlightedItemId?: string
@@ -8,7 +8,7 @@ import { SearchBar } from './SearchBar';
import { Table } from './Table';
type Props = {
title?: React.ReactNode;
title?: string;
titleIcon?: IconProps['icon'];
searchValue: string;
onSearchChange(value: string): void;
@@ -52,7 +52,7 @@ export function DatatableHeader({
return (
<Table.Title
id={titleId}
label={title}
label={title ?? ''}
icon={titleIcon}
description={description}
data-cy={dataCy}
@@ -6,14 +6,12 @@ interface Props {
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
noWidget?: boolean;
'aria-label'?: string;
id?: string;
}
export function TableContainer({
children,
noWidget = false,
'aria-label': ariaLabel,
id,
}: PropsWithChildren<Props>) {
if (noWidget) {
return (
@@ -27,7 +25,7 @@ export function TableContainer({
<div className="row">
<div className="col-sm-12">
<div className="datatable">
<Widget aria-label={ariaLabel} id={id}>
<Widget aria-label={ariaLabel}>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
</div>
@@ -5,7 +5,7 @@ import { Icon } from '@@/Icon';
interface Props {
icon?: ReactNode | ComponentType<unknown>;
label: React.ReactNode;
label: string;
description?: ReactNode;
className?: string;
id?: string;
-4
View File
@@ -99,10 +99,6 @@ export interface BasicTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export interface TableSettingsWithRefreshable
extends BasicTableSettings,
RefreshableTableSettings {}
export function createPersistedStore<T extends BasicTableSettings>(
storageKey: string,
initialSortBy?: string | { id: string; desc: boolean },
@@ -43,7 +43,7 @@ export function AdvancedMode({
id="environment-variables-editor"
value={editorValue}
onChange={handleEditorChange}
textTip="e.g. key=value"
placeholder="e.g. key=value"
data-cy={dataCy}
/>
</>
@@ -45,9 +45,8 @@ export function FormSection({
{title}
</FormSectionTitle>
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
{isExpanded && <div className="clear-both">{children}</div>}
{isExpanded && children}
</div>
);
}
@@ -10,8 +10,14 @@
.modal-content {
background-color: var(--bg-modal-content-color);
padding: 20px;
position: relative;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
outline: 0;
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
}
+2 -8
View File
@@ -39,7 +39,7 @@ export function Modal({
isOpen
className={clsx(
styles.overlay,
'flex items-center justify-center z-50'
'z-50 flex items-center justify-center'
)}
onDismiss={onDismiss}
role="dialog"
@@ -56,13 +56,7 @@ export function Modal({
}
)}
>
<div
className={clsx(
styles.modalContent,
'relative overflow-y-auto p-5 rounded-lg',
className
)}
>
<div className={clsx(styles.modalContent, 'relative', className)}>
{children}
{onDismiss && <CloseButton onClose={onDismiss} />}
</div>
@@ -1,15 +1,16 @@
import {
BasicTableSettings,
createPersistedStore,
refreshableSettings,
TableSettingsWithRefreshable,
RefreshableTableSettings,
} from '@@/datatables/types';
export interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings {}
export function createStore(storageKey: string) {
return createPersistedStore<TableSettingsWithRefreshable>(
storageKey,
'name',
(set) => ({
...refreshableSettings(set),
})
);
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
...refreshableSettings(set),
}));
}

Some files were not shown because too many files have changed in this diff Show More