Compare commits
15 Commits
2.21.0-rc1
...
2.21.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09348b8a25 | ||
|
|
1ef9c249b7 | ||
|
|
33ac61c600 | ||
|
|
bdb84617fe | ||
|
|
2d5c834590 | ||
|
|
280ca22aeb | ||
|
|
753150e03c | ||
|
|
517abc662a | ||
|
|
04e9ee3b3e | ||
|
|
273ea5df23 | ||
|
|
6cc95e11ae | ||
|
|
9133cbf544 | ||
|
|
111f641979 | ||
|
|
da370316df | ||
|
|
f69825d859 |
16
.github/workflows/ci.yaml
vendored
16
.github/workflows/ci.yaml
vendored
@@ -34,7 +34,6 @@ jobs:
|
||||
- { platform: linux, arch: arm64, version: "" }
|
||||
- { platform: linux, arch: arm, version: "" }
|
||||
- { platform: linux, arch: ppc64le, version: "" }
|
||||
- { platform: linux, arch: s390x, version: "" }
|
||||
- { platform: windows, arch: amd64, version: 1809 }
|
||||
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||
runs-on: ubuntu-latest
|
||||
@@ -146,31 +145,22 @@ jobs:
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le-alpine"
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
|
||||
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64"
|
||||
fi
|
||||
|
||||
@@ -19,8 +19,7 @@ type Service struct {
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(connection portainer.Connection) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
if err := connection.SetServiceName(BucketName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -32,6 +31,16 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID]{
|
||||
Bucket: BucketName,
|
||||
Connection: service.Connection,
|
||||
Tx: tx,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TeamByName returns a team by name.
|
||||
func (service *Service) TeamByName(name string) (*portainer.Team, error) {
|
||||
var t portainer.Team
|
||||
|
||||
48
api/dataservices/team/tx.go
Normal file
48
api/dataservices/team/tx.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID]
|
||||
}
|
||||
|
||||
// TeamByName returns a team by name.
|
||||
func (service ServiceTx) TeamByName(name string) (*portainer.Team, error) {
|
||||
var t portainer.Team
|
||||
|
||||
err := service.Tx.GetAll(
|
||||
BucketName,
|
||||
&portainer.Team{},
|
||||
dataservices.FirstFn(&t, func(e portainer.Team) bool {
|
||||
return strings.EqualFold(e.Name, name)
|
||||
}),
|
||||
)
|
||||
|
||||
if errors.Is(err, dataservices.ErrStop) {
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return nil, dserrors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// CreateTeam creates a new Team.
|
||||
func (service ServiceTx) Create(team *portainer.Team) error {
|
||||
return service.Tx.CreateObject(
|
||||
BucketName,
|
||||
func(id uint64) (int, any) {
|
||||
team.ID = portainer.TeamID(id)
|
||||
return int(team.ID), team
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -402,7 +402,6 @@ type storeExport struct {
|
||||
}
|
||||
|
||||
func (store *Store) Export(filename string) (err error) {
|
||||
|
||||
backup := storeExport{}
|
||||
|
||||
if c, err := store.CustomTemplate().ReadAll(); err != nil {
|
||||
@@ -606,6 +605,7 @@ func (store *Store) Export(filename string) (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filename, b, 0600)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,10 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
|
||||
return tx.store.TeamMembershipService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) Team() dataservices.TeamService { return nil }
|
||||
func (tx *StoreTx) Team() dataservices.TeamService {
|
||||
return tx.store.TeamService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil }
|
||||
|
||||
func (tx *StoreTx) User() dataservices.UserService {
|
||||
|
||||
@@ -941,6 +941,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.21.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.21.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,17 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/docker/docker/api/types"
|
||||
dockercontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -30,6 +32,44 @@ func NewContainerService(factory *dockerclient.ClientFactory, dataStore dataserv
|
||||
}
|
||||
}
|
||||
|
||||
// applyVersionConstraint uses the version to apply a transformation function to
|
||||
// the value when the constraint is satisfied
|
||||
func applyVersionConstraint[T any](currentVersion, versionConstraint string, value T, transform func(T) T) (T, error) {
|
||||
newValue := value
|
||||
|
||||
constraint, err := semver.NewConstraint(versionConstraint)
|
||||
if err != nil {
|
||||
return newValue, errors.New("invalid version constraint specified")
|
||||
}
|
||||
|
||||
currentVer, err := semver.NewVersion(currentVersion)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to parse the Docker client version")
|
||||
|
||||
return newValue, nil
|
||||
}
|
||||
|
||||
if satisfiesConstraint, _ := constraint.Validate(currentVer); satisfiesConstraint {
|
||||
newValue = transform(value)
|
||||
}
|
||||
|
||||
return newValue, nil
|
||||
}
|
||||
|
||||
func clearMacAddrs(n network.NetworkingConfig) network.NetworkingConfig {
|
||||
netConfig := network.NetworkingConfig{
|
||||
EndpointsConfig: make(map[string]*network.EndpointSettings),
|
||||
}
|
||||
|
||||
for k := range n.EndpointsConfig {
|
||||
endpointConfig := n.EndpointsConfig[k].Copy()
|
||||
endpointConfig.MacAddress = ""
|
||||
netConfig.EndpointsConfig[k] = endpointConfig
|
||||
}
|
||||
|
||||
return netConfig
|
||||
}
|
||||
|
||||
// Recreate a container
|
||||
func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.Endpoint, containerId string, forcePullImage bool, imageTag, nodeName string) (*types.ContainerJSON, error) {
|
||||
cli, err := c.factory.CreateClient(endpoint, nodeName, nil)
|
||||
@@ -90,7 +130,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
return nil, errors.Wrap(err, "rename container error")
|
||||
}
|
||||
|
||||
networkWithCreation := network.NetworkingConfig{
|
||||
initialNetwork := network.NetworkingConfig{
|
||||
EndpointsConfig: make(map[string]*network.EndpointSettings),
|
||||
}
|
||||
|
||||
@@ -103,10 +143,10 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
}
|
||||
|
||||
// 5. get the first network attached to the current container
|
||||
if len(networkWithCreation.EndpointsConfig) == 0 {
|
||||
if len(initialNetwork.EndpointsConfig) == 0 {
|
||||
// Retrieve the first network that is linked to the present container, which
|
||||
// will be utilized when creating the container.
|
||||
networkWithCreation.EndpointsConfig[name] = network
|
||||
initialNetwork.EndpointsConfig[name] = network
|
||||
}
|
||||
}
|
||||
c.sr.enable()
|
||||
@@ -130,7 +170,15 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
// to retain the same network settings we have to connect on creation to one of the old
|
||||
// container's networks, and connect to the other networks after creation.
|
||||
// see: https://portainer.atlassian.net/browse/EE-5448
|
||||
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &networkWithCreation, nil, container.Name)
|
||||
|
||||
// Docker API < 1.44 does not support specifying MAC addresses
|
||||
// https://github.com/moby/moby/blob/6aea26b431ea152a8b085e453da06ea403f89886/client/container_create.go#L44-L46
|
||||
initialNetwork, err = applyVersionConstraint(cli.ClientVersion(), "< 1.44", initialNetwork, clearMacAddrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &initialNetwork, nil, container.Name)
|
||||
|
||||
c.sr.push(func() {
|
||||
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
|
||||
@@ -150,8 +198,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
log.Debug().Str("container_id", newContainerId).Msg("connecting networks to container")
|
||||
networks := container.NetworkSettings.Networks
|
||||
for key, network := range networks {
|
||||
_, ok := networkWithCreation.EndpointsConfig[key]
|
||||
if ok {
|
||||
if _, ok := initialNetwork.EndpointsConfig[key]; ok {
|
||||
// skip the network that is used during container creation
|
||||
continue
|
||||
}
|
||||
|
||||
52
api/docker/container_test.go
Normal file
52
api/docker/container_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyVersionConstraint(t *testing.T) {
|
||||
initialNet := network.NetworkingConfig{
|
||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||
"key1": {
|
||||
MacAddress: "mac1",
|
||||
EndpointID: "endpointID1",
|
||||
},
|
||||
"key2": {
|
||||
MacAddress: "mac2",
|
||||
EndpointID: "endpointID2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
f := func(currentVer string, constraint string, success, emptyMac bool) {
|
||||
t.Helper()
|
||||
|
||||
transformedNet, err := applyVersionConstraint(currentVer, constraint, initialNet, clearMacAddrs)
|
||||
if success {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
require.Len(t, transformedNet.EndpointsConfig, len(initialNet.EndpointsConfig))
|
||||
|
||||
for k := range initialNet.EndpointsConfig {
|
||||
if emptyMac {
|
||||
require.NotEqual(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
|
||||
require.Empty(t, transformedNet.EndpointsConfig[k].MacAddress)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
require.Equal(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
|
||||
}
|
||||
}
|
||||
|
||||
f("1.45", "< 1.44", true, false) // No transformation
|
||||
f("1.43", "< 1.44", true, true) // Transformation
|
||||
f("a.b.", "< 1.44", true, false) // Invalid current version
|
||||
f("1.45", "z 1.44", false, false) // Invalid version constraint
|
||||
}
|
||||
@@ -143,6 +143,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
|
||||
ReferenceName: plumbing.ReferenceName(opt.referenceName),
|
||||
Auth: getAuth(opt.username, opt.password),
|
||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||
Tags: git.NoTags,
|
||||
}
|
||||
|
||||
repo, err := git.Clone(memory.NewStorage(), nil, cloneOption)
|
||||
@@ -166,7 +167,10 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
|
||||
}
|
||||
|
||||
var allPaths []string
|
||||
|
||||
w := object.NewTreeWalker(tree, true, nil)
|
||||
defer w.Close()
|
||||
|
||||
for {
|
||||
name, entry, err := w.Next()
|
||||
if err != nil {
|
||||
|
||||
@@ -91,6 +91,29 @@ func Test_latestCommitID(t *testing.T) {
|
||||
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
|
||||
}
|
||||
|
||||
func Test_ListRefs(t *testing.T) {
|
||||
service := Service{git: NewGitClient(true)}
|
||||
|
||||
repositoryURL := setup(t)
|
||||
|
||||
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"refs/heads/main"}, fs)
|
||||
}
|
||||
|
||||
func Test_ListFiles(t *testing.T) {
|
||||
service := Service{git: NewGitClient(true)}
|
||||
|
||||
repositoryURL := setup(t)
|
||||
referenceName := "refs/heads/main"
|
||||
|
||||
fs, err := service.ListFiles(repositoryURL, referenceName, "", "", false, false, []string{".yml"}, false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"docker-compose.yml"}, fs)
|
||||
}
|
||||
|
||||
func getCommitHistoryLength(t *testing.T, err error, dir string) int {
|
||||
repo, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -223,11 +224,23 @@ func (service *Service) ListRefs(repositoryURL, username, password string, hardR
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
var singleflightGroup = &singleflight.Group{}
|
||||
|
||||
// ListFiles will list all the files of the target repository with specific extensions.
|
||||
// If extension is not provided, it will list all the files under the target repository
|
||||
func (service *Service) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) {
|
||||
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
|
||||
|
||||
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
|
||||
return service.listFiles(repositoryURL, referenceName, username, password, dirOnly, hardRefresh, tlsSkipVerify)
|
||||
})
|
||||
|
||||
return filterFiles(fs.([]string), includedExts), err
|
||||
}
|
||||
|
||||
func (service *Service) listFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, tlsSkipVerify bool) ([]string, error) {
|
||||
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
|
||||
|
||||
if service.cacheEnabled && hardRefresh {
|
||||
// Should remove the cache explicitly, so that the following normal list can show the correct result
|
||||
service.repoFileCache.Remove(repoKey)
|
||||
@@ -235,14 +248,9 @@ func (service *Service) ListFiles(repositoryURL, referenceName, username, passwo
|
||||
|
||||
if service.repoFileCache != nil {
|
||||
// lookup the files cache first
|
||||
cache, ok := service.repoFileCache.Get(repoKey)
|
||||
if ok {
|
||||
files, success := cache.([]string)
|
||||
if success {
|
||||
// For the case while searching files in a repository without include extensions for the first time,
|
||||
// but with include extensions for the second time
|
||||
includedFiles := filterFiles(files, includedExts)
|
||||
return includedFiles, nil
|
||||
if cache, ok := service.repoFileCache.Get(repoKey); ok {
|
||||
if files, ok := cache.([]string); ok {
|
||||
return files, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,12 +282,11 @@ func (service *Service) ListFiles(repositoryURL, referenceName, username, passwo
|
||||
}
|
||||
}
|
||||
|
||||
includedFiles := filterFiles(files, includedExts)
|
||||
if service.cacheEnabled && service.repoFileCache != nil {
|
||||
service.repoFileCache.Add(repoKey, includedFiles)
|
||||
return includedFiles, nil
|
||||
service.repoFileCache.Add(repoKey, files)
|
||||
}
|
||||
return includedFiles, nil
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (service *Service) purgeCache() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
@@ -13,6 +14,13 @@ import (
|
||||
)
|
||||
|
||||
func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
||||
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
||||
isDockerDesktopExtension := false
|
||||
if val, ok := os.LookupEnv("DOCKER_EXTENSION"); ok && val == "1" {
|
||||
isDockerDesktopExtension = true
|
||||
}
|
||||
|
||||
handler = withSendCSRFToken(handler)
|
||||
|
||||
token := make([]byte, 32)
|
||||
@@ -27,7 +35,7 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
gorillacsrf.Secure(false),
|
||||
)(handler)
|
||||
|
||||
return withSkipCSRF(handler), nil
|
||||
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
||||
}
|
||||
|
||||
func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
@@ -48,10 +56,10 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func withSkipCSRF(handler http.Handler) http.Handler {
|
||||
func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
skip, err := security.ShouldSkipCSRFCheck(r)
|
||||
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
|
||||
return
|
||||
|
||||
@@ -28,5 +28,7 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
|
||||
|
||||
security.RemoveAuthCookie(w)
|
||||
|
||||
handler.bouncer.RevokeJWT(tokenData.Token)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
|
||||
imagesList := make([]ImageResponse, len(images))
|
||||
for i, image := range images {
|
||||
if (image.RepoTags == nil || len(image.RepoTags) == 0) && (image.RepoDigests != nil && len(image.RepoDigests) > 0) {
|
||||
if len(image.RepoTags) == 0 && len(image.RepoDigests) > 0 {
|
||||
for _, repoDigest := range image.RepoDigests {
|
||||
image.RepoTags = append(image.RepoTags, repoDigest[0:strings.Index(repoDigest, "@")]+":<none>")
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestMissingEdgeIdentifier(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code))
|
||||
t.Fatalf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ func TestWithEndpoints(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != test.expectedStatusCode {
|
||||
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID))
|
||||
t.Fatalf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func TestLastCheckInDateIncreases(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
|
||||
@@ -262,7 +262,7 @@ func TestEmptyEdgeIdWithAgentPlatformHeader(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code))
|
||||
t.Fatalf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
|
||||
@@ -326,7 +326,7 @@ func TestEdgeStackStatus(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
var data endpointEdgeStatusInspectResponse
|
||||
@@ -391,7 +391,7 @@ func TestEdgeJobsResponse(t *testing.T) {
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
var data endpointEdgeStatusInspectResponse
|
||||
|
||||
@@ -85,7 +85,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.21.0
|
||||
// @version 2.21.2
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -95,7 +96,7 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack management is disabled for non-admin users"
|
||||
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
|
||||
return httperror.Forbidden(errMsg, errors.New(errMsg))
|
||||
}
|
||||
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
|
||||
@@ -111,7 +111,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "stack deletion is disabled for non-admin users"
|
||||
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
|
||||
return httperror.Forbidden(errMsg, errors.New(errMsg))
|
||||
}
|
||||
|
||||
// stop scheduler updates of the stack before removal
|
||||
@@ -338,7 +338,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "stack deletion is disabled for non-admin users"
|
||||
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
|
||||
return httperror.Forbidden(errMsg, errors.New(errMsg))
|
||||
}
|
||||
|
||||
stacksToDelete = append(stacksToDelete, stack)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -23,6 +24,7 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Name) {
|
||||
return errors.New("Invalid team name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -43,26 +45,42 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error {
|
||||
// @router /teams [post]
|
||||
func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload teamCreatePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
team, err := handler.DataStore.Team().TeamByName(payload.Name)
|
||||
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.InternalServerError("Unable to retrieve teams from the database", err)
|
||||
var team *portainer.Team
|
||||
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
team, err = createTeam(tx, payload)
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, team)
|
||||
}
|
||||
|
||||
func createTeam(tx dataservices.DataStoreTx, payload teamCreatePayload) (*portainer.Team, error) {
|
||||
team, err := tx.Team().TeamByName(payload.Name)
|
||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve teams from the database", err)
|
||||
}
|
||||
if team != nil {
|
||||
return httperror.Conflict("A team with the same name already exists", errors.New("Team already exists"))
|
||||
return nil, httperror.Conflict("A team with the same name already exists", errors.New("Team already exists"))
|
||||
}
|
||||
|
||||
team = &portainer.Team{
|
||||
Name: payload.Name,
|
||||
}
|
||||
team = &portainer.Team{Name: payload.Name}
|
||||
|
||||
err = handler.DataStore.Team().Create(team)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist the team inside the database", err)
|
||||
if err := tx.Team().Create(team); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist the team inside the database", err)
|
||||
}
|
||||
|
||||
for _, teamLeader := range payload.TeamLeaders {
|
||||
@@ -72,11 +90,10 @@ func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *http
|
||||
Role: portainer.TeamLeader,
|
||||
}
|
||||
|
||||
err = handler.DataStore.TeamMembership().Create(membership)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist team leadership inside the database", err)
|
||||
if err := tx.TeamMembership().Create(membership); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist team leadership inside the database", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, team)
|
||||
return team, nil
|
||||
}
|
||||
|
||||
65
api/http/handler/teams/team_create_test.go
Normal file
65
api/http/handler/teams/team_create_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package teams
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func TestConcurrentTeamCreation(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
h := &Handler{
|
||||
DataStore: store,
|
||||
}
|
||||
|
||||
tcp := teamCreatePayload{
|
||||
Name: "portainer",
|
||||
}
|
||||
|
||||
m, err := json.Marshal(tcp)
|
||||
require.NoError(t, err)
|
||||
|
||||
errGroup := &errgroup.Group{}
|
||||
|
||||
n := 100
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
errGroup.Go(func() error {
|
||||
req, err := http.NewRequest(http.MethodPost, "/teams", bytes.NewReader(m))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.teamCreate(httptest.NewRecorder(), req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err = errGroup.Wait()
|
||||
require.Error(t, err)
|
||||
|
||||
teams, err := store.Team().ReadAll()
|
||||
require.NotEmpty(t, teams)
|
||||
require.NoError(t, err)
|
||||
|
||||
teamCreated := false
|
||||
for _, team := range teams {
|
||||
if team.Name == tcp.Name {
|
||||
require.False(t, teamCreated)
|
||||
teamCreated = true
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, teamCreated)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -54,12 +55,33 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().UserByUsername(payload.Username)
|
||||
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
var user *portainer.User
|
||||
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
user, err = handler.createUser(tx, payload)
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, user)
|
||||
}
|
||||
|
||||
func (handler *Handler) createUser(tx dataservices.DataStoreTx, payload userCreatePayload) (*portainer.User, error) {
|
||||
user, err := tx.User().UserByUsername(payload.Username)
|
||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
return httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
|
||||
return nil, httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
|
||||
}
|
||||
|
||||
user = &portainer.User{
|
||||
@@ -67,33 +89,33 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
|
||||
Role: portainer.UserRole(payload.Role),
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
settings, err := tx.Settings().Settings()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
// when ldap/oauth is on, can only add users without password
|
||||
// When LDAP/OAuth is on, can only add users without password
|
||||
if (settings.AuthenticationMethod == portainer.AuthenticationLDAP || settings.AuthenticationMethod == portainer.AuthenticationOAuth) && payload.Password != "" {
|
||||
errMsg := "A user with password can not be created when authentication method is Oauth or LDAP"
|
||||
return httperror.BadRequest(errMsg, errors.New(errMsg))
|
||||
errMsg := "a user with password can not be created when authentication method is Oauth or LDAP"
|
||||
return nil, httperror.BadRequest(errMsg, errors.New(errMsg))
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
||||
if !handler.passwordStrengthChecker.Check(payload.Password) {
|
||||
return httperror.BadRequest("Password does not meet the requirements", nil)
|
||||
return nil, httperror.BadRequest("Password does not meet the requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.Password)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
return nil, httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.DataStore.User().Create(user)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist user inside the database", err)
|
||||
if err := tx.User().Create(user); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist user inside the database", err)
|
||||
}
|
||||
|
||||
hideFields(user)
|
||||
return response.JSON(w, user)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
77
api/http/handler/users/user_create_test.go
Normal file
77
api/http/handler/users/user_create_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type mockPasswordStrengthChecker struct{}
|
||||
|
||||
func (m *mockPasswordStrengthChecker) Check(string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestConcurrentUserCreation(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
h := &Handler{
|
||||
passwordStrengthChecker: &mockPasswordStrengthChecker{},
|
||||
CryptoService: &crypto.Service{},
|
||||
DataStore: store,
|
||||
}
|
||||
|
||||
ucp := userCreatePayload{
|
||||
Username: "portainer",
|
||||
Password: "password",
|
||||
Role: int(portainer.AdministratorRole),
|
||||
}
|
||||
|
||||
m, err := json.Marshal(ucp)
|
||||
require.NoError(t, err)
|
||||
|
||||
errGroup := &errgroup.Group{}
|
||||
|
||||
n := 100
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
errGroup.Go(func() error {
|
||||
req, err := http.NewRequest(http.MethodPost, "/users", bytes.NewReader(m))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.userCreate(httptest.NewRecorder(), req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err = errGroup.Wait()
|
||||
require.Error(t, err)
|
||||
|
||||
users, err := store.User().ReadAll()
|
||||
require.NotEmpty(t, users)
|
||||
require.NoError(t, err)
|
||||
|
||||
userCreated := false
|
||||
for _, u := range users {
|
||||
if u.Username == ucp.Username {
|
||||
require.False(t, userCreated)
|
||||
userCreated = true
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, userCreated)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -16,6 +17,9 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const apiKeyHeader = "X-API-KEY"
|
||||
const jwtTokenHeader = "Authorization"
|
||||
|
||||
type (
|
||||
BouncerService interface {
|
||||
PublicAccess(http.Handler) http.Handler
|
||||
@@ -30,6 +34,7 @@ type (
|
||||
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
|
||||
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
RevokeJWT(string)
|
||||
}
|
||||
|
||||
// RequestBouncer represents an entity that manages API request accesses
|
||||
@@ -37,6 +42,7 @@ type (
|
||||
dataStore dataservices.DataStore
|
||||
jwtService portainer.JWTService
|
||||
apiKeyService apikey.APIKeyService
|
||||
revokedJWT sync.Map
|
||||
}
|
||||
|
||||
// RestrictedRequestContext is a data structure containing information
|
||||
@@ -52,16 +58,22 @@ type (
|
||||
tokenLookup func(*http.Request) (*portainer.TokenData, error)
|
||||
)
|
||||
|
||||
const apiKeyHeader = "X-API-KEY"
|
||||
const jwtTokenHeader = "Authorization"
|
||||
var (
|
||||
ErrInvalidKey = errors.New("Invalid API key")
|
||||
ErrRevokedJWT = errors.New("the JWT has been revoked")
|
||||
)
|
||||
|
||||
// NewRequestBouncer initializes a new RequestBouncer
|
||||
func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer {
|
||||
return &RequestBouncer{
|
||||
b := &RequestBouncer{
|
||||
dataStore: dataStore,
|
||||
jwtService: jwtService,
|
||||
apiKeyService: apiKeyService,
|
||||
}
|
||||
|
||||
go b.cleanUpExpiredJWT()
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// PublicAccess defines a security check for public API endpoints.
|
||||
@@ -317,11 +329,15 @@ func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.Tok
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, ok := bouncer.revokedJWT.Load(jti); ok {
|
||||
return nil, ErrRevokedJWT
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
@@ -333,15 +349,44 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenD
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, ok := bouncer.revokedJWT.Load(jti); ok {
|
||||
return nil, ErrRevokedJWT
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
var ErrInvalidKey = errors.New("Invalid API key")
|
||||
func (bouncer *RequestBouncer) RevokeJWT(token string) {
|
||||
_, jti, exp, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bouncer.revokedJWT.Store(jti, exp)
|
||||
}
|
||||
|
||||
func (bouncer *RequestBouncer) cleanUpExpiredJWTPass() {
|
||||
bouncer.revokedJWT.Range(func(key, value any) bool {
|
||||
if time.Now().After(value.(time.Time)) {
|
||||
bouncer.revokedJWT.Delete(key)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (bouncer *RequestBouncer) cleanUpExpiredJWT() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
|
||||
for range ticker.C {
|
||||
bouncer.cleanUpExpiredJWTPass()
|
||||
}
|
||||
}
|
||||
|
||||
// apiKeyLookup looks up an verifies an api-key by:
|
||||
// - computing the digest of the raw api-key
|
||||
@@ -528,7 +573,12 @@ func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Hand
|
||||
// - public routes
|
||||
// - kubectl - a bearer token is needed, and no csrf token can be sent
|
||||
// - api token
|
||||
func ShouldSkipCSRFCheck(r *http.Request) (bool, error) {
|
||||
// - docker desktop extension
|
||||
func ShouldSkipCSRFCheck(r *http.Request, isDockerDesktopExtension bool) (bool, error) {
|
||||
if isDockerDesktopExtension {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
cookie, _ := r.Cookie(portainer.AuthCookieKey)
|
||||
hasCookie := cookie != nil && cookie.Value != ""
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testHandler200 is a simple handler which returns HTTP status 200 OK
|
||||
@@ -386,40 +388,52 @@ func Test_apiKeyLookup(t *testing.T) {
|
||||
func Test_ShouldSkipCSRFCheck(t *testing.T) {
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
cookieValue string
|
||||
apiKey string
|
||||
authHeader string
|
||||
expectedResult bool
|
||||
expectedError bool
|
||||
name string
|
||||
cookieValue string
|
||||
apiKey string
|
||||
authHeader string
|
||||
isDockerDesktopExtension bool
|
||||
expectedResult bool
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "Should return false when cookie is present",
|
||||
cookieValue: "test-cookie",
|
||||
name: "Should return false (not skip) when cookie is present",
|
||||
cookieValue: "test-cookie",
|
||||
isDockerDesktopExtension: false,
|
||||
},
|
||||
{
|
||||
name: "Should return true when cookie is not present",
|
||||
cookieValue: "",
|
||||
expectedResult: true,
|
||||
name: "Should return true (skip) when cookie is present and docker desktop extension is true",
|
||||
cookieValue: "test-cookie",
|
||||
isDockerDesktopExtension: true,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return true when api key is present",
|
||||
cookieValue: "",
|
||||
apiKey: "test-api-key",
|
||||
expectedResult: true,
|
||||
name: "Should return true (skip) when cookie is not present",
|
||||
cookieValue: "",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return true when auth header is present",
|
||||
cookieValue: "",
|
||||
authHeader: "test-auth-header",
|
||||
expectedResult: true,
|
||||
name: "Should return true (skip) when api key is present",
|
||||
cookieValue: "",
|
||||
apiKey: "test-api-key",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return false and error when both api key and auth header are present",
|
||||
cookieValue: "",
|
||||
apiKey: "test-api-key",
|
||||
authHeader: "test-auth-header",
|
||||
expectedError: true,
|
||||
name: "Should return true (skip) when auth header is present",
|
||||
cookieValue: "",
|
||||
authHeader: "test-auth-header",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return false (not skip) and error when both api key and auth header are present",
|
||||
cookieValue: "",
|
||||
apiKey: "test-api-key",
|
||||
authHeader: "test-auth-header",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -437,7 +451,7 @@ func Test_ShouldSkipCSRFCheck(t *testing.T) {
|
||||
req.Header.Set(jwtTokenHeader, test.authHeader)
|
||||
}
|
||||
|
||||
result, err := ShouldSkipCSRFCheck(req)
|
||||
result, err := ShouldSkipCSRFCheck(req, test.isDockerDesktopExtension)
|
||||
is.Equal(test.expectedResult, result)
|
||||
if test.expectedError {
|
||||
is.Error(err)
|
||||
@@ -447,3 +461,60 @@ func Test_ShouldSkipCSRFCheck(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRevocation(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.User().Create(&portainer.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
jwtService.SetUserSessionDuration(time.Second)
|
||||
|
||||
token, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKeyService := apikey.NewAPIKeyService(nil, nil)
|
||||
|
||||
bouncer := NewRequestBouncer(store, jwtService, apiKeyService)
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, "url", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.Header.Add(jwtTokenHeader, "Bearer "+token)
|
||||
|
||||
r.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: token})
|
||||
|
||||
_, err = bouncer.JWTAuthLookup(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = bouncer.CookieAuthLookup(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
bouncer.RevokeJWT(token)
|
||||
|
||||
revokeLen := func() (l int) {
|
||||
bouncer.revokedJWT.Range(func(key, value any) bool {
|
||||
l++
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return l
|
||||
}
|
||||
require.Equal(t, 1, revokeLen())
|
||||
|
||||
_, err = bouncer.JWTAuthLookup(r)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = bouncer.CookieAuthLookup(r)
|
||||
require.Error(t, err)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
bouncer.cleanUpExpiredJWTPass()
|
||||
|
||||
require.Equal(t, 0, revokeLen())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/unique"
|
||||
)
|
||||
|
||||
var ErrEdgeGroupNotFound = errors.New("edge group was not found")
|
||||
@@ -32,7 +33,7 @@ func EdgeStackRelatedEndpoints(edgeGroupIDs []portainer.EdgeGroupID, endpoints [
|
||||
edgeStackEndpoints = append(edgeStackEndpoints, EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)...)
|
||||
}
|
||||
|
||||
return edgeStackEndpoints, nil
|
||||
return unique.Unique(edgeStackEndpoints), nil
|
||||
}
|
||||
|
||||
type EndpointRelationsConfig struct {
|
||||
|
||||
@@ -58,6 +58,8 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (testRequestBouncer) RevokeJWT(jti string) {}
|
||||
|
||||
// AddTestSecurityCookie adds a security cookie to the request
|
||||
func AddTestSecurityCookie(r *http.Request, jwt string) {
|
||||
r.AddCookie(&http.Cookie{
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/portainer/portainer/api/internal/securecookie"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -103,7 +104,7 @@ func (service *Service) GenerateToken(data *portainer.TokenData) (string, time.T
|
||||
}
|
||||
|
||||
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
|
||||
func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) {
|
||||
func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, string, time.Time, error) {
|
||||
scope := parseScope(token)
|
||||
secret := service.secrets[scope]
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
@@ -119,10 +120,10 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
|
||||
|
||||
user, err := service.dataStore.User().Read(portainer.UserID(cl.UserID))
|
||||
if err != nil {
|
||||
return nil, errInvalidJWTToken
|
||||
return nil, "", time.Time{}, errInvalidJWTToken
|
||||
}
|
||||
if user.TokenIssueAt > cl.StandardClaims.IssuedAt {
|
||||
return nil, errInvalidJWTToken
|
||||
return nil, "", time.Time{}, errInvalidJWTToken
|
||||
}
|
||||
|
||||
return &portainer.TokenData{
|
||||
@@ -131,10 +132,11 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
|
||||
Role: portainer.UserRole(cl.Role),
|
||||
Token: token,
|
||||
ForceChangePassword: cl.ForceChangePassword,
|
||||
}, nil
|
||||
}, cl.Id, time.Unix(cl.ExpiresAt, 0), nil
|
||||
}
|
||||
}
|
||||
return nil, errInvalidJWTToken
|
||||
|
||||
return nil, "", time.Time{}, errInvalidJWTToken
|
||||
}
|
||||
|
||||
// parse a JWT token, fallback to defaultScope if no scope is present in the JWT
|
||||
@@ -173,6 +175,11 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
|
||||
expiresAt = time.Now().Add(time.Hour * 8760 * 99).Unix()
|
||||
}
|
||||
|
||||
uuid, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to generate the JWT ID: %w", err)
|
||||
}
|
||||
|
||||
cl := claims{
|
||||
UserID: int(data.ID),
|
||||
Username: data.Username,
|
||||
@@ -180,6 +187,7 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
|
||||
Scope: scope,
|
||||
ForceChangePassword: data.ForceChangePassword,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Id: uuid.String(),
|
||||
ExpiresAt: expiresAt,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
},
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
i "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateSignedToken(t *testing.T) {
|
||||
@@ -55,3 +57,56 @@ func TestGenerateSignedToken_InvalidScope(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "invalid scope: testing", err.Error())
|
||||
}
|
||||
|
||||
func TestGenerationAndParsing(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
err := store.User().Create(&portainer.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := NewService("1h", store)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedToken := &portainer.TokenData{
|
||||
Username: "User",
|
||||
ID: 1,
|
||||
Role: 1,
|
||||
}
|
||||
|
||||
tokenString, _, err := service.GenerateToken(expectedToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedToken.Token = tokenString
|
||||
|
||||
token, _, _, err := service.ParseAndVerifyToken(tokenString)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedToken, token)
|
||||
}
|
||||
|
||||
func TestExpiration(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
err := store.User().Create(&portainer.User{ID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := NewService("1h", store)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedToken := &portainer.TokenData{
|
||||
Username: "User",
|
||||
ID: 1,
|
||||
Role: 1,
|
||||
}
|
||||
|
||||
service.SetUserSessionDuration(time.Second)
|
||||
|
||||
tokenString, _, err := service.GenerateToken(expectedToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedToken.Token = tokenString
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
_, _, _, err = service.ParseAndVerifyToken(tokenString)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -1490,7 +1490,7 @@ type (
|
||||
JWTService interface {
|
||||
GenerateToken(data *TokenData) (string, time.Time, error)
|
||||
GenerateTokenForKubeconfig(data *TokenData) (string, error)
|
||||
ParseAndVerifyToken(token string) (*TokenData, error)
|
||||
ParseAndVerifyToken(token string) (*TokenData, string, time.Time, error)
|
||||
SetUserSessionDuration(userSessionDuration time.Duration)
|
||||
}
|
||||
|
||||
@@ -1601,7 +1601,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.21.0"
|
||||
APIVersion = "2.21.2"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
1
app/assets/ico/vendor/akamai.svg
vendored
Normal file
1
app/assets/ico/vendor/akamai.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="409.333" height="166.751"><defs><clipPath id="a" clipPathUnits="userSpaceOnUse"><path d="M0 0h612v792H0Z"/></clipPath><clipPath id="b" clipPathUnits="userSpaceOnUse"><path d="M0 0h612v792H0Z"/></clipPath></defs><g style="fill:#09c;fill-opacity:1"><path d="M0 0c-6.127 1.862-10.58 7.517-10.58 14.209 0 6.763 4.548 12.465 10.768 14.279.637.189.472.59-.306.59-8.271 0-14.986-6.669-14.986-14.869S-8.389-.66-.118-.66C.66-.66.707-.212 0 0" style="fill:#09c;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(5.60732 0 0 -5.60732 84.693 163.05)"/></g><g style="fill:#000"><path d="M0 0a10 10 0 0 0-.071 1.202 11.797 11.797 0 0 0 11.806 11.805c6.173 0 8.011-2.757 8.247-2.568.259.188-2.239 5.655-9.473 5.655A11.796 11.796 0 0 1-1.296 4.289c0-1.508.283-2.946.801-4.265C-.283-.542.047-.542 0 0" style="fill:#09c;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(5.60732 0 0 -5.60732 41.355 102.799)"/></g><g style="fill:#09c;fill-opacity:1"><path d="M0 0c3.063 1.343 6.928 1.39 10.721.047 2.545-.895 4.03-2.168 4.148-2.097.212.094-1.485 2.757-4.525 3.912A10.79 10.79 0 0 1-.165.259C-.495 0-.377-.165 0 0" style="fill:#09c;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(5.60732 0 0 -5.60732 69.102 55.1)"/></g><g style="fill:#f93;fill-opacity:1"><path d="M0 0c0-.848-.707-1.555-1.555-1.555-.849 0-1.555.683-1.555 1.555 0 .848.683 1.555 1.555 1.555S0 .872 0 0" style="fill:#f93;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(5.60732 0 0 -5.60732 409.333 86.283)"/></g><path d="m525.457 729.914.188-2.074h3.276l-1.108 12.041h-4.877l-6.174-12.04h3.346l1.037 2.073zm-.165 2.333H522.3l2.57 5.207h.022zM533.469 733.096h.495l2.333 3.18h3.039l-3.228-4.052 1.98-4.383h-3.229l-1.296 3.417h-.471l-.73-3.417h-2.757l2.544 12.04h2.757z" style="fill:#f93;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(5.60732 0 0 -5.60732 -2820.89 4227.904)"/><g clip-path="url(#b)" style="fill:#f93;fill-opacity:1" transform="matrix(5.60732 0 0 -5.60732 -2820.89 4227.904)"><g style="fill:#f93;fill-opacity:1"><path d="M0 0h2.757l1.107 5.255C4.477 8.153 3.37 8.506.542 8.506c-1.979 0-3.888.024-4.43-2.591h2.757c.165.754.636.918 1.32.918 1.201 0 1.154-.494.989-1.272L.895 4.218H.778c-.095.966-1.32.942-2.098.942-2.002 0-3.181-.636-3.605-2.662-.447-2.144.566-2.616 2.498-2.616.966 0 2.262.189 2.71 1.343h.094Zm-.778 3.487c.896 0 1.485-.07 1.343-.777C.377 1.838 0 1.673-1.155 1.673c-.424 0-1.201 0-1.013.919.165.778.707.895 1.39.895" style="fill:#f93;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="translate(543.649 727.84)"/></g><g style="fill:#f93;fill-opacity:1"><path d="m0 0-.259-1.178h.118C.401-.188 1.508.094 2.451.094c1.178 0 2.356-.212 2.191-1.649h.118C5.16-.353 6.386.094 7.446.094c1.956 0 2.781-.801 2.356-2.757L8.577-8.436H5.82l1.037 4.878c.141.872.283 1.532-.778 1.532-1.084 0-1.437-.707-1.626-1.626L3.44-8.436H.683l1.084 5.114c.142.777.189 1.296-.777 1.296-1.131 0-1.485-.613-1.697-1.626L-1.72-8.436h-2.757L-2.686 0Z" style="fill:#f93;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="translate(551.99 736.277)"/></g><g style="fill:#f93;fill-opacity:1"><path d="M0 0h2.757l1.131 5.255c.613 2.898-.495 3.251-3.322 3.251-1.98 0-3.889.024-4.43-2.591h2.757c.164.754.636.918 1.319.918 1.202 0 1.155-.494.99-1.272L.919 4.218H.801c-.094.966-1.319.942-2.097.942-2.003 0-3.181-.636-3.605-2.662-.448-2.144.565-2.616 2.497-2.616.967 0 2.263.189 2.71 1.343h.095Zm-.754 3.487c.895 0 1.484-.07 1.343-.777-.188-.872-.565-1.037-1.72-1.037-.424 0-1.202 0-1.013.919.165.778.707.895 1.39.895" style="fill:#f93;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="translate(566.906 727.84)"/></g></g><path d="M573.669 727.84h-2.757l1.767 8.437h2.78z" style="fill:#f93;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(5.60732 0 0 -5.60732 -2820.89 4227.904)"/></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
1
app/assets/ico/vendor/linode.svg
vendored
1
app/assets/ico/vendor/linode.svg
vendored
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32"><path d="M9.545 14.42l-1.2-8.258L3.37 3.074l1.612 7.8 4.562 3.556zm1.38 9.443l-.852-5.823-4.356-3.63 1.17 5.648 4.038 3.804zm-3.383-.64l.862 4.165 3.596 3.817L11.386 27l-3.842-3.78zm11.644-1.806l-1.837-1.402.014.33a.19.19 0 0 1-.084.166l-1.386.934 1.507 1.23c.02.02.03.027.035.036l.022.042c.008.027.01.037.01.048l.064 1.45 1.7 1.423-.036-4.26zm6.3-4.507l-.36 4.153-1.2-.828.13-2.118c0-.024-.002-.033-.003-.04-.006-.032-.012-.046-.02-.06s-.02-.028-.032-.04a.23.23 0 0 0-.032-.028l-2.56-1.69.037-1.856 4.03 2.51" fill="#123d10"/><path d="M16.59 11.116l-.335-7.84-7.53 2.894 1.23 8.4 6.635-3.453zm.4 9.135l-.246-5.78-6.27 3.57.88 6.01 5.638-3.798zm.127 2.93l-5.333 3.816.648 4.422 4.872-3.88-.186-4.357zm2.465-1.762l.036 4.275 3.8-3.032.253-4.17-4.1 2.926zm9.48-6.782l-.534 3.955-2.998 2.4.352-4.068 3.18-2.276" fill="#33b652"/><path d="M17.472 22.812l-.008-.042a.21.21 0 0 0-.019-.044c-.015-.024-.023-.032-.03-.04l-1.52-1.24 1.386-.934a.19.19 0 0 0 .084-.166l-.014-.33 1.837 1.402.036 4.26-1.7-1.423-.062-1.44zm-7.398-4.772l.852 5.823-4.038-3.804-1.17-5.648 4.356 3.63zm6.904 2.212L11.34 24.05l-.88-6.01 6.27-3.57.246 5.78zm-.725-16.975l.335 7.84-6.635 3.453-1.23-8.4 7.53-2.894zM8.335 6.16l1.2 8.258-4.562-3.556-1.612-7.8L8.335 6.16zm.07 21.225l-.862-4.165L11.386 27l.615 4.203-3.596-3.817zm8.885.152l-4.872 3.88-.648-4.422 5.333-3.816.186 4.357zm6.116-4.876l-3.8 3.032-.036-4.275 4.1-2.926-.253 4.17zm.53-2.428l.13-2.118c0-.024-.002-.033-.003-.04-.006-.032-.012-.046-.02-.06s-.02-.028-.032-.04a.23.23 0 0 0-.032-.028l-2.56-1.69.037-1.856 4.03 2.51-.36 4.153-1.2-.828zm1.58.747l.352-4.068 3.18-2.276-.534 3.955-2.998 2.4zm3.97-6.77l-.006-.03c-.002-.01-.006-.02-.01-.03a.23.23 0 0 0-.027-.045c-.02-.023-.03-.03-.04-.038l-4.368-2.42c-.06-.033-.133-.032-.192.008l-3.674 2.246c-.006 0-.01.01-.016.013s-.013.01-.02.015l-.016.02c-.005.008-.01.01-.014.018s-.008.017-.01.026-.006.013-.008.02-.003.02-.004.03l-.042 1.97-1.494-.987c-.062-.04-.142-.042-.205 0l-2.15 1.314-.093-2.186-.007-.042c-.002-.008-.004-.013-.007-.02a.19.19 0 0 0-.011-.024c-.004-.008-.008-.013-.013-.02s-.01-.013-.015-.02-.012-.01-.02-.016l-2.25-1.514 2.094-1.1c.066-.034.106-.104.103-.178l-.352-8.228c-.001-.01-.003-.02-.005-.03-.006-.03-.013-.045-.022-.06s-.022-.03-.032-.04c-.017-.017-.022-.02-.028-.024-.017-.008-.02-.008-.022-.015L10.873.115a.19.19 0 0 0-.14-.011L3.036 2.502l-.05.028-.04.037c-.006.008-.01.015-.014.023s-.01.015-.013.024-.006.02-.01.03c-.006.03-.005.04-.005.05s0 .018.001.027l1.718 8.302c.01.044.034.084.07.112l2.33 1.817-1.685.802c-.02.008-.022.015-.026.016l-.027.023c-.022.024-.028.036-.034.047-.014.028-.02.045-.02.062a.24.24 0 0 0 .002.055l1.292 6.25a.19.19 0 0 0 .056.1l1.622 1.528-1.075.658c-.014.008-.026.02-.038.03-.017.02-.025.033-.032.045a.22.22 0 0 0-.021.065c-.002.018-.001.036.003.055l1 4.842c.007.034.024.066.048.092l4.048 4.298c.006.008.013.01.02.017.02.017.033.024.047.03.027.008.05.013.072.013s.038 0 .056-.01.022-.008.027-.015c.008 0 .014-.01.02-.014l5.223-4.157c.048-.04.074-.097.072-.157l-.122-2.85 1.74 1.464c.02.015.03.023.04.028s.02.008.025.015c.02.008.038.01.057.01s.037-.008.056-.01c.017-.008.022-.008.026-.015.01-.008.017-.01.026-.017l4.186-3.337c.043-.034.068-.084.072-.138l.127-2.09 1.27.884c.012.008.015.015.02.015.007.008.015.008.023.01.033.015.05.015.067.015s.038 0 .056-.01.02-.008.026-.015c.01-.008.02-.012.03-.018l3.415-2.722c.04-.03.064-.076.07-.124l.604-4.47.001-.037" fill="#231f20"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -11,7 +11,8 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<base id="base" />
|
||||
<script>
|
||||
if (window.origin == 'file://') {
|
||||
// http://localhost:49000 is a docker extension specific url (see /build/docker-extension/docker-compose.yml)
|
||||
if (window.origin == 'http://localhost:49000') {
|
||||
// we are loading the app from a local file as in docker extension
|
||||
document.getElementById('base').href = 'http://localhost:49000/';
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import google from '@/assets/ico/vendor/google.svg?c';
|
||||
import googlecloud from '@/assets/ico/vendor/googlecloud.svg?c';
|
||||
import kubernetes from '@/assets/ico/vendor/kubernetes.svg?c';
|
||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||
import linode from '@/assets/ico/vendor/linode.svg?c';
|
||||
import akamai from '@/assets/ico/vendor/akamai.svg?c';
|
||||
import microsoft from '@/assets/ico/vendor/microsoft.svg?c';
|
||||
import microsofticon from '@/assets/ico/vendor/microsoft-icon.svg?c';
|
||||
import openldap from '@/assets/ico/vendor/openldap.svg?c';
|
||||
@@ -62,7 +62,7 @@ export const SvgIcons = {
|
||||
googlecloud,
|
||||
kubernetes,
|
||||
helm,
|
||||
linode,
|
||||
akamai,
|
||||
microsoft,
|
||||
microsofticon,
|
||||
openldap,
|
||||
|
||||
@@ -56,9 +56,9 @@ export function KubeConfigTeaserForm() {
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Note: Officially supported cloud providers are Civo, Linode,
|
||||
DigitalOcean and Microsoft Azure (others are not guaranteed to
|
||||
work at present)
|
||||
Note: Officially supported cloud providers are Civo, Akamai
|
||||
Connected Cloud, DigitalOcean and Microsoft Azure (others are
|
||||
not guaranteed to work at present)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,8 +47,12 @@ export function HomeView() {
|
||||
We could not connect your local environment to Portainer.
|
||||
<br />
|
||||
Please ensure your environment is correctly exposed. For
|
||||
help with installation visit
|
||||
<a href="https://documentation.portainer.io/quickstart/">
|
||||
help with installation visit{' '}
|
||||
<a
|
||||
href="https://documentation.portainer.io/quickstart/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
https://documentation.portainer.io/quickstart
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"docker": "v27.0.3",
|
||||
"dockerCompose": "v2.28.1",
|
||||
"helm": "v3.15.2",
|
||||
"kubectl": "v1.30.2",
|
||||
"docker": "v27.1.2",
|
||||
"dockerCompose": "v2.29.2",
|
||||
"helm": "v3.15.4",
|
||||
"kubectl": "v1.31.0",
|
||||
"mingit": "2.45.2.1"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ build-remote:
|
||||
docker buildx build -f build/linux/Dockerfile --push --builder=buildx-multi-arch --platform=windows/amd64,linux/amd64,linux/arm64 --build-arg TAG=$(VERSION) --build-arg PORTAINER_IMAGE_NAME=$(IMAGE_NAME) --tag=$(TAGGED_IMAGE_NAME) .
|
||||
|
||||
install:
|
||||
docker extension install $(TAGGED_IMAGE_NAME)
|
||||
docker extension install $(TAGGED_IMAGE_NAME) --force
|
||||
|
||||
dev:
|
||||
docker extension dev debug $(IMAGE_NAME)
|
||||
|
||||
multiarch:
|
||||
docker buildx create --name=buildx-multi-arch --driver=docker-container --driver-opt=network=host
|
||||
|
||||
@@ -20,10 +20,9 @@ Next you must install the CLI plugin to enable extension development. Please fol
|
||||
|
||||
### Build from local changes
|
||||
|
||||
1. Run `yarn` to install the project dependencies
|
||||
2. Run `yarn dev:extension` to install the extension
|
||||
3. Make your code changes
|
||||
4. Re-run `yarn dev:extension` to rebuild and re-install with your latest changes
|
||||
1. Run `make dev-extension` to install the project dependencies and start in development mode (note that this doesn't do live updates for frontend changes).
|
||||
2. Make your code changes
|
||||
3. Re-run `make dev-extension` to rebuild and re-install with your latest changes
|
||||
|
||||
## Accessing the Portainer extension
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"dashboard-tab": {
|
||||
"title": "Portainer",
|
||||
"root": "/public",
|
||||
"src": "index.html"
|
||||
"src": "http://localhost:49000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
go.mod
4
go.mod
@@ -2,7 +2,7 @@ module github.com/portainer/portainer
|
||||
|
||||
go 1.21
|
||||
|
||||
toolchain go1.21.11
|
||||
toolchain go1.21.12
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/coreos/go-semver v0.3.0
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5
|
||||
github.com/docker/cli v26.0.2+incompatible
|
||||
github.com/docker/docker v26.0.2+incompatible
|
||||
github.com/docker/docker v26.1.5+incompatible
|
||||
github.com/fvbommel/sortorder v1.0.2
|
||||
github.com/fxamacker/cbor/v2 v2.4.0
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||
|
||||
4
go.sum
4
go.sum
@@ -94,8 +94,8 @@ github.com/docker/cli v26.0.2+incompatible h1:4C4U8ZqrlNDe/R1U1zFFX+YsCFiVUicJqo
|
||||
github.com/docker/cli v26.0.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v26.0.2+incompatible h1:yGVmKUFGgcxA6PXWAokO0sQL22BrQ67cgVjko8tGdXE=
|
||||
github.com/docker/docker v26.0.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
|
||||
github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo=
|
||||
github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.21.0",
|
||||
"version": "2.21.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -110,6 +110,11 @@ func (wrapper *PluginWrapper) Validate(ctx context.Context, filePaths []string,
|
||||
return err
|
||||
}
|
||||
|
||||
func (wrapper *PluginWrapper) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
|
||||
configArgs := append([]string{"config"}, options.ConfigOptions...)
|
||||
return wrapper.command(newCommand(configArgs, filePaths), options)
|
||||
}
|
||||
|
||||
// Command execute a docker-compose command
|
||||
func (wrapper *PluginWrapper) command(command composeCommand, options libstack.Options) ([]byte, error) {
|
||||
program := utils.ProgramPath(wrapper.binaryPath, "docker-compose")
|
||||
|
||||
@@ -143,3 +143,230 @@ func containerExists(containerName string) bool {
|
||||
|
||||
return strings.Contains(string(out), containerName)
|
||||
}
|
||||
|
||||
func Test_Config(t *testing.T) {
|
||||
checkPrerequisites(t)
|
||||
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
projectName := "configtest"
|
||||
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
composeFileContent string
|
||||
expectFileContent string
|
||||
envFileContent string
|
||||
}{
|
||||
{
|
||||
name: "compose file with relative path",
|
||||
composeFileContent: `services:
|
||||
app:
|
||||
image: 'nginx:latest'
|
||||
ports:
|
||||
- '80:80'
|
||||
volumes:
|
||||
- ./nginx-data:/data`,
|
||||
expectFileContent: `name: configtest
|
||||
services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
networks:
|
||||
default: null
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 80
|
||||
published: "80"
|
||||
protocol: tcp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./nginx-data
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: true
|
||||
networks:
|
||||
default:
|
||||
name: configtest_default
|
||||
`,
|
||||
envFileContent: "",
|
||||
},
|
||||
{
|
||||
name: "compose file with absolute path",
|
||||
composeFileContent: `services:
|
||||
app:
|
||||
image: 'nginx:latest'
|
||||
ports:
|
||||
- '80:80'
|
||||
volumes:
|
||||
- /nginx-data:/data`,
|
||||
expectFileContent: `name: configtest
|
||||
services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
networks:
|
||||
default: null
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 80
|
||||
published: "80"
|
||||
protocol: tcp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /nginx-data
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: true
|
||||
networks:
|
||||
default:
|
||||
name: configtest_default
|
||||
`,
|
||||
envFileContent: "",
|
||||
},
|
||||
{
|
||||
name: "compose file with declared volume",
|
||||
composeFileContent: `services:
|
||||
app:
|
||||
image: 'nginx:latest'
|
||||
ports:
|
||||
- '80:80'
|
||||
volumes:
|
||||
- nginx-data:/data
|
||||
volumes:
|
||||
nginx-data:
|
||||
driver: local`,
|
||||
expectFileContent: `name: configtest
|
||||
services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
networks:
|
||||
default: null
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 80
|
||||
published: "80"
|
||||
protocol: tcp
|
||||
volumes:
|
||||
- type: volume
|
||||
source: nginx-data
|
||||
target: /data
|
||||
volume: {}
|
||||
networks:
|
||||
default:
|
||||
name: configtest_default
|
||||
volumes:
|
||||
nginx-data:
|
||||
name: configtest_nginx-data
|
||||
driver: local
|
||||
`,
|
||||
envFileContent: "",
|
||||
},
|
||||
{
|
||||
name: "compose file with relative path environment variable placeholder",
|
||||
composeFileContent: `services:
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- 8019:80
|
||||
volumes:
|
||||
- ${WEB_HOME}:/usr/share/nginx/html/
|
||||
env_file:
|
||||
- stack.env
|
||||
`,
|
||||
expectFileContent: `name: configtest
|
||||
services:
|
||||
nginx:
|
||||
environment:
|
||||
WEB_HOME: ./html
|
||||
image: nginx:latest
|
||||
networks:
|
||||
default: null
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 80
|
||||
published: "8019"
|
||||
protocol: tcp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./html
|
||||
target: /usr/share/nginx/html
|
||||
bind:
|
||||
create_host_path: true
|
||||
networks:
|
||||
default:
|
||||
name: configtest_default
|
||||
`,
|
||||
envFileContent: `WEB_HOME=./html`,
|
||||
},
|
||||
{
|
||||
name: "compose file with absolute path environment variable placeholder",
|
||||
composeFileContent: `services:
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- 8019:80
|
||||
volumes:
|
||||
- ${WEB_HOME}:/usr/share/nginx/html/
|
||||
env_file:
|
||||
- stack.env
|
||||
`,
|
||||
|
||||
expectFileContent: `name: configtest
|
||||
services:
|
||||
nginx:
|
||||
environment:
|
||||
WEB_HOME: /usr/share/nginx/html
|
||||
image: nginx:latest
|
||||
networks:
|
||||
default: null
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 80
|
||||
published: "8019"
|
||||
protocol: tcp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /usr/share/nginx/html
|
||||
target: /usr/share/nginx/html
|
||||
bind:
|
||||
create_host_path: true
|
||||
networks:
|
||||
default:
|
||||
name: configtest_default
|
||||
`,
|
||||
envFileContent: `WEB_HOME=/usr/share/nginx/html`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
composeFilePath, err := createFile(dir, "docker-compose.yml", tc.composeFileContent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
envFilePath := ""
|
||||
if tc.envFileContent != "" {
|
||||
envFilePath, err = createFile(dir, "stack.env", tc.envFileContent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
w := setup(t)
|
||||
actual, err := w.Config(ctx, []string{composeFilePath}, libstack.Options{
|
||||
WorkingDir: dir,
|
||||
ProjectName: projectName,
|
||||
EnvFilePath: envFilePath,
|
||||
ConfigOptions: []string{"--no-path-resolution"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get config: %s. Error: %s", string(actual), err)
|
||||
}
|
||||
|
||||
if string(actual) != tc.expectFileContent {
|
||||
t.Fatalf("unexpected config output: %s(len=%d), expect: %s(len=%d)", actual, len(actual), tc.expectFileContent, len(tc.expectFileContent))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ type Deployer interface {
|
||||
Pull(ctx context.Context, filePaths []string, options Options) error
|
||||
Validate(ctx context.Context, filePaths []string, options Options) error
|
||||
WaitForStatus(ctx context.Context, name string, status Status) <-chan WaitResult
|
||||
Config(ctx context.Context, filePaths []string, options Options) ([]byte, error)
|
||||
}
|
||||
|
||||
type Status string
|
||||
@@ -47,6 +48,8 @@ type Options struct {
|
||||
// By default, it is an empty string, which means it corresponds to the path of the compose file itself.
|
||||
// This is particularly helpful when mounting a relative path.
|
||||
ProjectDir string
|
||||
// ConfigOptions is a list of options to pass to the docker-compose config command
|
||||
ConfigOptions []string
|
||||
}
|
||||
|
||||
type DeployOptions struct {
|
||||
|
||||
Reference in New Issue
Block a user