Compare commits

...

14 Commits

Author SHA1 Message Date
andres-portainer
b46bff06c6 fix: 2.24 regressions (#190)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2024-12-03 15:25:53 +13:00
Oscar Zhou
5d311031e3 version: bump version to 2.24.1 (#187) 2024-12-03 09:10:19 +13:00
andres-portainer
99de11894c fix(compose): fix support for ECR BE-11392 (#150) 2024-11-18 16:42:49 -03:00
andres-portainer
02c006be8a fix(stacks): pass the registry credentials to Compose stacks BE-11388 (#148)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2024-11-18 08:39:16 +13:00
andres-portainer
60a2696a8d fix(libstack): add missing private registry credentials BE-11388 (#144) 2024-11-15 17:39:00 -03:00
Oscar Zhou
64b5d1df2d fix(swarm): failed to deploy app template [BE-11385] (#135) 2024-11-15 11:19:33 +13:00
andres-portainer
025a409ab5 fix(compose): avoid leftovers in Run() BE-11381 (#130) 2024-11-13 20:24:14 -03:00
andres-portainer
9b65f01748 feat(edgestacks): add a retry period to edge stack deployments BE-11155 (#128)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-11-13 20:22:07 -03:00
andres-portainer
e112ddfbeb fix(libstack): fix compose run BE-11381 (#127) 2024-11-13 14:38:44 -03:00
LP B
707fc91a32 fix(edge/stacks): use default namespace when none is specified in manifest (#125) 2024-11-13 16:30:16 +13:00
andres-portainer
b2eb4388fd fix(libstack): add a different timeout for WaitForStatus BE-11376 (#119) 2024-11-12 19:30:42 -03:00
andres-portainer
5a451b2035 fix(compose): provide the project name for proper validation BE-11375 (#117) 2024-11-12 17:18:36 -03:00
Oscar Zhou
370d224d76 fix(libstack): empty project name [BE-10801] (#115) 2024-11-12 10:20:41 -03:00
Ali
b4c36b0e48 fix(configmap): create portainer configmap if it doesn't exist [r8s-141] #113 (#114) 2024-11-12 18:34:58 +13:00
32 changed files with 515 additions and 287 deletions

View File

@@ -49,7 +49,6 @@ import (
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libstack"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/gofrs/uuid"
@@ -166,26 +165,6 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
if err != nil {
log.Fatal().Err(err).Msg("failed creating compose manager")
}
return composeWrapper
}
func initSwarmStackManager(
assetsPath string,
configPath string,
signatureService portainer.DigitalSignatureService,
fileService portainer.FileService,
reverseTunnelService portainer.ReverseTunnelService,
dataStore dataservices.DataStore,
) (portainer.SwarmStackManager, error) {
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
}
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)
}
@@ -435,9 +414,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
composeDeployer := compose.NewComposeDeployer()
composeStackManager := initComposeStackManager(composeDeployer, proxyManager)
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
}

View File

@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.24.0",
"KubectlShellImage": "portainer/kubectl-shell:2.24.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -942,7 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.24.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.24.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -31,15 +31,18 @@ type (
// RegistryCredentials holds the credentials for a Docker registry.
// Used only for EE
RegistryCredentials []RegistryCredentials
// PrePullImage is a flag indicating if the agent should pull the image before deploying the stack.
// PrePullImage is a flag indicating if the agent must pull the image before deploying the stack.
// Used only for EE
PrePullImage bool
// RePullImage is a flag indicating if the agent should pull the image if it is already present on the node.
// RePullImage is a flag indicating if the agent must pull the image if it is already present on the node.
// Used only for EE
RePullImage bool
// RetryDeploy is a flag indicating if the agent should retry to deploy the stack if it fails.
// RetryDeploy is a flag indicating if the agent must retry to deploy the stack if it fails.
// Used only for EE
RetryDeploy bool
// RetryPeriod specifies the duration, in seconds, for which the agent should continue attempting to deploy the stack after a failure
// Used only for EE
RetryPeriod int
// EdgeUpdateID is the ID of the edge update related to this stack.
// Used only for EE
EdgeUpdateID int

View File

@@ -9,27 +9,32 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack"
"github.com/docker/cli/cli/config/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ComposeStackManager is a wrapper for docker-compose binary
type ComposeStackManager struct {
deployer libstack.Deployer
proxyManager *proxy.Manager
dataStore dataservices.DataStore
}
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
// NewComposeStackManager returns a Compose stack manager
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager {
return &ComposeStackManager{
deployer: deployer,
proxyManager: proxyManager,
}, nil
dataStore: dataStore,
}
}
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
@@ -60,6 +65,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
@@ -90,6 +96,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
Remove: options.Remove,
Args: options.Args,
@@ -103,8 +110,7 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
} else if proxy != nil {
defer proxy.Close()
}
@@ -120,12 +126,11 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file,
// but does not start containers based on those images.
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
} else if proxy != nil {
defer proxy.Close()
}
@@ -140,6 +145,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
}
@@ -178,12 +184,12 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
// Copy from default .env file
defaultEnvPath := path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
if err = copyDefaultEnvFile(envfile, defaultEnvPath); err != nil {
if err := copyDefaultEnvFile(envfile, defaultEnvPath); err != nil {
return "", err
}
// Copy from stack env vars
if err = copyConfigEnvVars(envfile, stack.Env); err != nil {
if err := copyConfigEnvVars(envfile, stack.Env); err != nil {
return "", err
}
@@ -219,3 +225,49 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
}
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}

View File

@@ -48,10 +48,7 @@ func Test_UpAndDown(t *testing.T) {
deployer := compose.NewComposeDeployer()
w, err := NewComposeStackManager(deployer, nil)
if err != nil {
t.Fatalf("Failed creating manager: %s", err)
}
w := NewComposeStackManager(deployer, nil, nil)
ctx := context.TODO()

View File

@@ -11,7 +11,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
@@ -46,8 +45,7 @@ func NewSwarmStackManager(
dataStore: datastore,
}
err := manager.updateDockerCLIConfiguration(manager.configPath)
if err != nil {
if err := manager.updateDockerCLIConfiguration(manager.configPath); err != nil {
return nil, err
}
@@ -63,33 +61,14 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
for _, registry := range registries {
if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
continue
}
username, password, err := registryutils.GetRegEffectiveCredential(&registry)
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
if err := runCommandAndCaptureStdErr(command, registryArgs, nil, ""); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
@@ -155,6 +134,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
@@ -167,8 +147,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
cmd.Env = append(cmd.Env, env...)
}
err := cmd.Run()
if err != nil {
if err := cmd.Run(); err != nil {
return errors.New(stderr.String())
}
@@ -192,6 +171,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
}
@@ -216,6 +196,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
configFilePath := path.Join(configPath, "config.json")
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
return err
@@ -246,8 +227,7 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
return make(map[string]any), nil
}
err = json.Unmarshal(raw, &config)
if err != nil {
if err := json.Unmarshal(raw, &config); err != nil {
return nil, err
}

View File

@@ -26,11 +26,10 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
}
var edgeStack *portainer.EdgeStack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData.ID, r)
return err
})
if err != nil {
}); err != nil {
switch {
case httperrors.IsInvalidPayloadError(err):
return httperror.BadRequest("Invalid payload", err)

View File

@@ -57,17 +57,15 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
}
var payload updateEdgeStackPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
var stack *portainer.EdgeStack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stack, err = handler.updateEdgeStack(tx, portainer.EdgeStackID(stackID), payload)
return err
})
if err != nil {
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -122,14 +120,12 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
stack.EdgeGroups = groupsIds
if payload.UpdateVersion {
err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds)
if err != nil {
if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
return nil, httperror.InternalServerError("Unable to update stack version", err)
}
}
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
if err != nil {
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
@@ -160,8 +156,7 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
delete(relation.EdgeStacks, edgeStackID)
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}
@@ -181,8 +176,7 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
relation.EdgeStacks[edgeStackID] = true
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}

View File

@@ -83,7 +83,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.24.0
// @version 2.24.1
// @description.markdown api-description.md
// @termsOfService

View File

@@ -14,47 +14,51 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
}
func doGetRegToken(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
if err != nil {
return
return err
}
registry.AccessToken = *accessToken
registry.AccessTokenExpiry = expiryAt.Unix()
err = dataStore.Registry().Update(registry.ID, registry)
return
return tx.Registry().Update(registry.ID, registry)
}
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
return ecrClient.ParseAuthorizationToken(registry.AccessToken)
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
}
func EnsureRegTokenValid(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
if registry.Type == portainer.EcrRegistry {
if isRegTokenValid(registry) {
log.Debug().Msg("current ECR token is still valid")
} else {
err = doGetRegToken(dataStore, registry)
if err != nil {
log.Debug().Msg("refresh ECR token")
}
}
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if registry.Type != portainer.EcrRegistry {
return nil
}
return
if isRegTokenValid(registry) {
log.Debug().Msg("current ECR token is still valid")
return nil
}
if err := doGetRegToken(tx, registry); err != nil {
log.Debug().Msg("refresh ECR token")
return err
}
return nil
}
func GetRegEffectiveCredential(registry *portainer.Registry) (username, password string, err error) {
username = registry.Username
password = registry.Password
if registry.Type == portainer.EcrRegistry {
username, password, err = parseRegToken(registry)
} else {
username = registry.Username
password = registry.Password
}
return
}

View File

@@ -6,6 +6,8 @@ import (
portainer "github.com/portainer/portainer/api"
)
var _ portainer.ComposeStackManager = &composeStackManager{}
type composeStackManager struct{}
func NewComposeStackManager() *composeStackManager {
@@ -31,6 +33,6 @@ func (manager *composeStackManager) Down(ctx context.Context, stack *portainer.S
return nil
}
func (manager *composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
return nil
}

View File

@@ -1367,7 +1367,13 @@ type (
ValidateFlags(flags *CLIFlags) error
}
ComposeOptions struct {
Registries []Registry
}
ComposeUpOptions struct {
ComposeOptions
// ForceRecreate forces to recreate containers
ForceRecreate bool
// AbortOnContainerExit will stop the deployment if a container exits.
@@ -1379,6 +1385,8 @@ type (
}
ComposeRunOptions struct {
ComposeOptions
// Remove will remove the container after it has stopped
Remove bool
// Args are the arguments to pass to the container
@@ -1394,7 +1402,7 @@ type (
Run(ctx context.Context, stack *Stack, endpoint *Endpoint, serviceName string, options ComposeRunOptions) error
Up(ctx context.Context, stack *Stack, endpoint *Endpoint, options ComposeUpOptions) error
Down(ctx context.Context, stack *Stack, endpoint *Endpoint) error
Pull(ctx context.Context, stack *Stack, endpoint *Endpoint) error
Pull(ctx context.Context, stack *Stack, endpoint *Endpoint, options ComposeOptions) error
}
// CryptoService represents a service for encrypting/hashing data
@@ -1609,7 +1617,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.24.0"
APIVersion = "2.24.1"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax

View File

@@ -58,23 +58,25 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
d.lock.Lock()
defer d.lock.Unlock()
d.swarmStackManager.Login(registries, endpoint)
defer d.swarmStackManager.Logout(endpoint)
options := portainer.ComposeOptions{Registries: registries}
// --force-recreate doesn't pull updated images
if forcePullImage {
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint); err != nil {
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, options); err != nil {
return err
}
}
err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ForceRecreate: forceRecreate,
})
if err != nil {
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options,
ForceRecreate: forceRecreate,
}); err != nil {
d.composeStackManager.Down(context.TODO(), stack, endpoint)
return err
}
return err
return nil
}
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {

View File

@@ -59,8 +59,7 @@ func (d *stackDeployer) DeployRemoteComposeStack(
// --force-recreate doesn't pull updated images
if forcePullImage {
err := d.composeStackManager.Pull(context.TODO(), stack, endpoint)
if err != nil {
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, portainer.ComposeOptions{}); err != nil {
return err
}
}

View File

@@ -5,82 +5,110 @@
library css for buttons is overriden by `.widget .widget-body button`
so we have to force margin: 0
*/
.react-datetime-picker .react-calendar button {
.react-daterange-picker__calendar .react-calendar button {
margin: 0 !important;
}
/*
Extending Calendar.css from react-datetime-picker
Extending Calendar.css from react-daterange-picker__calendar
*/
.react-datetime-picker .react-calendar {
.react-daterange-picker__calendar .react-calendar {
background: var(--bg-calendar-color);
color: var(--text-main-color);
}
/* calendar nav buttons */
.react-datetime-picker .react-calendar__navigation button:disabled {
background-color: var(--bg-calendar-color);
.react-daterange-picker__calendar .react-calendar__navigation button:disabled {
background: var(--bg-calendar-color);
@apply opacity-60;
@apply brightness-95 th-dark:brightness-110;
}
.react-datetime-picker .react-calendar__navigation button:enabled:hover,
.react-datetime-picker .react-calendar__navigation button:enabled:focus {
background-color: var(--bg-daterangepicker-color);
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover,
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus {
background: var(--bg-daterangepicker-color);
}
/* date tile */
.react-datetime-picker .react-calendar__tile:disabled {
background-color: var(--bg-calendar-color);
.react-daterange-picker__calendar .react-calendar__tile:disabled {
background: var(--bg-calendar-color);
@apply opacity-60;
@apply brightness-95 th-dark:brightness-110;
}
.react-datetime-picker .react-calendar__tile:enabled:hover,
.react-datetime-picker .react-calendar__tile:enabled:focus {
background-color: var(--bg-daterangepicker-hover);
.react-daterange-picker__calendar .react-calendar__tile:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile:enabled:focus {
background: var(--bg-daterangepicker-hover);
}
/* today's date tile */
.react-datetime-picker .react-calendar__tile--now {
/* use background color to avoid white on yellow in dark/high contrast modes */
.react-daterange-picker__calendar .react-calendar__tile--now {
@apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)];
border-radius: 0.25rem !important;
}
.react-datetime-picker .react-calendar__tile--now:enabled:hover,
.react-datetime-picker .react-calendar__tile--now:enabled:focus {
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
}
/* probably date tile in range */
.react-datetime-picker .react-calendar__tile--hasActive {
.react-daterange-picker__calendar .react-calendar__tile--hasActive {
background: var(--bg-daterangepicker-end-date);
color: var(--text-daterangepicker-end-date);
}
.react-datetime-picker .react-calendar__tile--hasActive:enabled:hover,
.react-datetime-picker .react-calendar__tile--hasActive:enabled:focus {
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
}
/* selected date tile */
.react-datetime-picker .react-calendar__tile--active {
background: var(--bg-daterangepicker-active);
color: var(--text-daterangepicker-active);
}
.react-datetime-picker .react-calendar__tile--active:enabled:hover,
.react-datetime-picker .react-calendar__tile--active:enabled:focus {
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
}
.react-daterange-picker__calendar
.react-calendar__month-view__days__day:hover:not(.react-daterange-picker__calendar .react-calendar__tile--hoverEnd):not(
.react-daterange-picker__calendar .react-calendar__tile--hoverStart
):not(.react-calendar__tile--active) {
border-radius: 0.25rem !important;
}
/* on range select hover */
.react-datetime-picker .react-calendar--selectRange .react-calendar__tile--hover {
background-color: var(--bg-daterangepicker-in-range);
.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover {
background: var(--bg-daterangepicker-in-range);
color: var(--text-daterangepicker-in-range);
}
/*
Extending DateTimePicker.css from react-datetime-picker
Extending DateTimePicker.css from react-daterange-picker__calendar
*/
.react-datetime-picker .react-datetime-picker--disabled {
.react-daterange-picker__calendar .react-daterange-picker__calendar--disabled {
@apply opacity-40;
}
/* selected date tile */
.react-daterange-picker__calendar .react-calendar__tile--active {
background: var(--bg-daterangepicker-active) !important;
color: var(--text-daterangepicker-active) !important;
}
.react-daterange-picker__calendar .react-calendar__tile--rangeStart:not(.react-calendar__tile--rangeEnd),
.react-daterange-picker__calendar .react-calendar__tile--hoverStart {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.react-daterange-picker__calendar .react-calendar__tile--rangeEnd:not(.react-calendar__tile--rangeStart),
.react-daterange-picker__calendar .react-calendar__tile--hoverEnd {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
.react-daterange-picker__calendar .react-calendar__month-view__days__day--weekend {
color: inherit;
}
.react-calendar__tile--active.react-calendar__month-view__days__day--weekend {
color: var(--text-daterangepicker-active);
}

View File

@@ -52,7 +52,7 @@ export async function createSwarmStackFromGit({
}: SwarmGitRepositoryPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('standalone', 'repository'),
buildCreateUrl('swarm', 'repository'),
payload,
{
params: { endpointId: environmentId },

View File

@@ -14,13 +14,8 @@ type StringPortBinding = {
containerPort: number;
};
type NumericPortBinding = {
hostPort: number;
protocol: Protocol;
containerPort: number;
};
type RangePortBinding = {
hostIp: string;
hostPort: Range;
protocol: Protocol;
containerPort: Range;
@@ -42,9 +37,7 @@ export function toViewModel(portBindings: PortMap): Values {
return value === 'tcp' || value === 'udp';
}
function parsePorts(
portBindings: PortMap
): Array<StringPortBinding | NumericPortBinding> {
function parsePorts(portBindings: PortMap): Array<StringPortBinding> {
return Object.entries(portBindings).flatMap(([key, bindings]) => {
const [containerPort, protocol] = key.split('/');
@@ -63,15 +56,24 @@ export function toViewModel(portBindings: PortMap): Values {
}
return bindings.map((binding) => {
let port = '';
if (binding.HostPort) {
port = binding.HostPort;
}
if (binding.HostIp) {
port = `${binding.HostIp}:${port}`;
}
if (binding.HostPort?.includes('-')) {
// Range port
return {
hostPort: binding.HostPort,
hostPort: port,
protocol,
containerPort: containerPortNumber,
};
}
return {
hostPort: parseInt(binding.HostPort || '0', 10),
hostPort: port,
protocol,
containerPort: containerPortNumber,
};
@@ -79,9 +81,9 @@ export function toViewModel(portBindings: PortMap): Values {
});
}
function sortPorts(ports: Array<StringPortBinding | NumericPortBinding>) {
const rangePorts = ports.filter(isStringPortBinding);
const nonRangePorts = ports.filter(isNumericPortBinding);
function sortPorts(ports: Array<StringPortBinding>) {
const rangePorts = ports.filter(isRangePortBinding);
const nonRangePorts = ports.filter((port) => !isRangePortBinding(port));
return {
rangePorts,
@@ -93,27 +95,40 @@ export function toViewModel(portBindings: PortMap): Values {
};
}
function combinePorts(ports: Array<NumericPortBinding>) {
function combinePorts(ports: Array<StringPortBinding>) {
return ports
.reduce((acc, port) => {
let hostIp = '';
let hostPort = 0;
if (port.hostPort.includes(':')) {
const [ipStr, portStr] = port.hostPort.split(':');
hostIp = ipStr;
hostPort = parseInt(portStr || '0', 10);
} else {
hostPort = parseInt(port.hostPort || '0', 10);
}
const lastPort = acc[acc.length - 1];
if (
lastPort &&
lastPort.hostIp === hostIp &&
lastPort.containerPort.end === port.containerPort - 1 &&
lastPort.hostPort.end === port.hostPort - 1 &&
lastPort.hostPort.end === hostPort - 1 &&
lastPort.protocol === port.protocol
) {
lastPort.hostIp = hostIp;
lastPort.containerPort.end = port.containerPort;
lastPort.hostPort.end = port.hostPort;
lastPort.hostPort.end = hostPort;
return acc;
}
return [
...acc,
{
hostIp,
hostPort: {
start: port.hostPort,
end: port.hostPort,
start: hostPort,
end: hostPort,
},
containerPort: {
start: port.containerPort,
@@ -123,34 +138,32 @@ export function toViewModel(portBindings: PortMap): Values {
},
];
}, [] as Array<RangePortBinding>)
.map(({ protocol, containerPort, hostPort }) => ({
hostPort: getRange(hostPort.start, hostPort.end),
.map(({ protocol, containerPort, hostPort, hostIp }) => ({
hostPort: getRange(hostPort.start, hostPort.end, hostIp),
containerPort: getRange(containerPort.start, containerPort.end),
protocol,
}));
function getRange(start: number, end: number): string {
function getRange(start: number, end: number, hostIp?: string): string {
if (start === end) {
if (start === 0) {
return '';
}
if (hostIp) {
return `${hostIp}:${start}`;
}
return start.toString();
}
if (hostIp) {
return `${hostIp}:${start}-${end}`;
}
return `${start}-${end}`;
}
}
}
function isNumericPortBinding(
port: StringPortBinding | NumericPortBinding
): port is NumericPortBinding {
return port.hostPort !== 'string';
}
function isStringPortBinding(
port: StringPortBinding | NumericPortBinding
): port is StringPortBinding {
return port.hostPort === 'string';
function isRangePortBinding(port: StringPortBinding): boolean {
return port.hostPort.includes('-');
}

View File

@@ -57,10 +57,15 @@ export async function buildImageFromDockerfileContentAndFiles(
const dockerfile = new Blob([content], { type: 'text/plain' });
const uploadFiles = [dockerfile, ...files];
const formData = new FormData();
uploadFiles.forEach((file, index) => {
formData.append(`file${index}`, file);
});
return buildImage(
environmentId,
{ t: names },
{ file: uploadFiles },
formData,
'multipart/form-data'
);
}

View File

@@ -19,12 +19,27 @@ export function useUpdateK8sConfigMapMutation(
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
data,
configMap,
configMapName,
}: {
data: ConfigMap;
configMap: ConfigMap;
configMapName: string;
}) => updateConfigMap(environmentId, namespace, configMapName, data),
}) => {
if (!configMap.metadata?.uid) {
return createConfigMap(
environmentId,
namespace,
configMapName,
configMap
);
}
return updateConfigMap(
environmentId,
namespace,
configMapName,
configMap
);
},
...withInvalidate(queryClient, [
configMapQueryKeys.configMaps(environmentId, namespace),
]),
@@ -50,3 +65,22 @@ async function updateConfigMap(
);
}
}
function createConfigMap(
environmentId: EnvironmentId,
namespace: string,
configMap: string,
data: ConfigMap
) {
try {
return axios.post(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps`,
data
);
} catch (e) {
throw parseKubernetesAxiosError(
e,
`Unable to create ConfigMap '${configMap}'`
);
}
}

View File

@@ -95,7 +95,7 @@ export function AccessDatatable() {
configMap
);
await updateConfigMapMutation.mutateAsync({
data: configMapPayload,
configMap: configMapPayload,
configMapName: PortainerNamespaceAccessesConfigMap.configMapName,
});
notifySuccess('Success', 'Namespace access updated');

View File

@@ -12,6 +12,8 @@ import { useConfigMap } from '@/react/kubernetes/configs/queries/useConfigMap';
import { useTeams } from '@/react/portainer/users/teams/queries';
import { useUpdateK8sConfigMapMutation } from '@/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Configuration } from '@/react/kubernetes/configs/types';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { TextTip } from '@@/Tip/TextTip';
@@ -28,6 +30,7 @@ export function CreateAccessWidget() {
const {
params: { id: namespaceName },
} = useCurrentStateAndParams();
const { user } = useCurrentUser();
const environmentId = useEnvironmentId();
const isRBACEnabledQuery = useIsRBACEnabled(environmentId);
const initialValues: {
@@ -75,7 +78,9 @@ export function CreateAccessWidget() {
initialValues={initialValues}
enableReinitialize
validationSchema={validationSchema}
onSubmit={onSubmit}
onSubmit={(values, formikHelpers) =>
onSubmit(values, formikHelpers)
}
validateOnMount
>
{(formikProps) => (
@@ -104,10 +109,10 @@ export function CreateAccessWidget() {
namespaceAccesses,
values.selectedUsersAndTeams,
namespaceName,
configMap
configMap ?? newConfigMap(user.Username, user.Id)
);
await updateConfigMapMutation.mutateAsync({
data: configMapPayload,
configMap: configMapPayload,
configMapName: PortainerNamespaceAccessesConfigMap.configMapName,
});
notifySuccess('Success', 'Namespace access updated');
@@ -117,3 +122,18 @@ export function CreateAccessWidget() {
}
}
}
function newConfigMap(userName: string, userId: number) {
const configMap: Configuration = {
Type: 1,
UID: '',
Name: PortainerNamespaceAccessesConfigMap.configMapName,
Namespace: PortainerNamespaceAccessesConfigMap.namespace,
Data: { [PortainerNamespaceAccessesConfigMap.accessKey]: '{}' },
ConfigurationOwner: userName,
ConfigurationOwnerId: `${userId}`,
IsUsed: false,
Yaml: '',
};
return configMap;
}

View File

@@ -1,8 +1,19 @@
export interface ActivityLog {
interface BaseActivityLog {
timestamp: number;
action: string;
context: string;
id: number;
payload: object;
username: string;
}
export interface ActivityLogResponse extends BaseActivityLog {
payload: string;
}
export interface ActivityLog extends BaseActivityLog {
payload: string | object;
}
export interface ActivityLogsResponse {
logs: Array<ActivityLogResponse>;
totalCount: number;
}

View File

@@ -4,7 +4,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { isBE } from '../../feature-flags/feature-flags.service';
import { ActivityLog } from './types';
import { ActivityLogResponse, ActivityLogsResponse } from './types';
export const sortKeys = ['Context', 'Action', 'Timestamp', 'Username'] as const;
export type SortKey = (typeof sortKeys)[number];
@@ -30,19 +30,18 @@ export function useActivityLogs(query: Query) {
queryKey: ['activityLogs', query] as const,
queryFn: () => fetchActivityLogs(query),
keepPreviousData: true,
select: (data) => ({
...data,
logs: decorateLogs(data.logs),
}),
});
}
interface ActivityLogsResponse {
logs: Array<ActivityLog>;
totalCount: number;
}
async function fetchActivityLogs(query: Query): Promise<ActivityLogsResponse> {
try {
if (!isBE) {
return {
logs: [{}, {}, {}, {}, {}] as Array<ActivityLog>,
logs: [{}, {}, {}, {}, {}] as Array<ActivityLogResponse>,
totalCount: 5,
};
}
@@ -56,3 +55,40 @@ async function fetchActivityLogs(query: Query): Promise<ActivityLogsResponse> {
throw parseAxiosError(err, 'Failed loading user activity logs csv');
}
}
/**
* Decorates logs with the payload parsed from base64
*/
function decorateLogs(logs?: ActivityLogResponse[]) {
if (!logs || logs.length === 0) {
return [];
}
return logs.map((log) => ({
...log,
payload: parseBase64AsObject(log.payload),
}));
}
function parseBase64AsObject(value: string): string | object {
if (!value) {
return value;
}
try {
return JSON.parse(safeAtob(value));
} catch (err) {
return safeAtob(value);
}
}
function safeAtob(value: string) {
if (!value) {
return value;
}
try {
return window.atob(value);
} catch (err) {
// If the payload is not base64 encoded, return the original value
return value;
}
}

2
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/aws/aws-sdk-go-v2 v1.24.1
github.com/aws/aws-sdk-go-v2/credentials v1.16.16
github.com/aws/aws-sdk-go-v2/service/ecr v1.14.0
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1
github.com/cbroglie/mustache v1.4.0
github.com/compose-spec/compose-go/v2 v2.0.2
github.com/containers/image/v5 v5.30.1

9
go.sum
View File

@@ -57,7 +57,6 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o=
@@ -66,16 +65,14 @@ github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5g
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4/go.mod h1:XHgQ7Hz2WY2GAn//UXHofLfPXWh+s62MbMOijrg12Lw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/service/ecr v1.14.0 h1:AAZJJAENsQ4yYbnfvqPZT8Nc1YlEd5CZ4usymlC2b4U=
github.com/aws/aws-sdk-go-v2/service/ecr v1.14.0/go.mod h1:a3WUi3JjM3MFtIYenSYPJ7UZPXsw7U7vzebnynxucks=
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1 h1:zqXEIhuR7RcHob2gxB/Xf1X4XuMS0vapn7xr+wCPrpg=
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1/go.mod h1:+rWYJfms9p+D/wUN599tx3FtWvxoXCP25b8Porlrxcc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
@@ -86,7 +83,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
github.com/aws/smithy-go v1.10.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/beorn7/perks v0.0.0-20150223135152-b965b613227f/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -315,7 +311,6 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.24.0",
"version": "2.24.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"

View File

@@ -4,10 +4,10 @@ import (
"context"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"github.com/portainer/portainer/pkg/libstack"
@@ -18,9 +18,12 @@ import (
"github.com/docker/cli/cli/flags"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
"github.com/docker/docker/registry"
"github.com/rs/zerolog/log"
)
var mu sync.Mutex
func withCli(
ctx context.Context,
options libstack.Options,
@@ -39,25 +42,20 @@ func withCli(
opts.Hosts = []string{options.Host}
}
tempDir, err := os.MkdirTemp("", "docker-config")
if err != nil {
return fmt.Errorf("unable to create a temporary directory for the Docker config: %w", err)
}
defer os.RemoveAll(tempDir)
opts.ConfigDir = tempDir
mu.Lock()
if err := cli.Initialize(opts); err != nil {
mu.Unlock()
return fmt.Errorf("unable to initialize the Docker client: %w", err)
}
mu.Unlock()
defer cli.Client().Close()
for _, r := range options.Registries {
creds := cli.ConfigFile().GetCredentialsStore(r.ServerAddress)
if err := creds.Store(r); err != nil {
return fmt.Errorf("unable to store the Docker credentials: %w", err)
if r.ServerAddress == "" || r.ServerAddress == registry.DefaultNamespace {
r.ServerAddress = registry.IndexServer
}
cli.ConfigFile().AuthConfigs[r.ServerAddress] = r
}
return cliFn(ctx, cli)
@@ -72,29 +70,24 @@ func withComposeService(
return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error {
composeService := compose.NewComposeService(cli)
configDetails := types.ConfigDetails{WorkingDir: options.WorkingDir}
if len(filePaths) == 0 {
return composeFn(composeService, nil)
}
env, err := parseEnvironment(options)
if err != nil {
return err
}
configDetails := types.ConfigDetails{
Environment: env,
WorkingDir: filepath.Dir(filePaths[0]),
}
for _, p := range filePaths {
configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p})
}
envFile := make(map[string]string)
if options.EnvFilePath != "" {
env, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
if err != nil {
return fmt.Errorf("unable to get the environment from the env file: %w", err)
}
maps.Copy(envFile, env)
configDetails.Environment = env
}
if len(configDetails.ConfigFiles) == 0 {
return composeFn(composeService, nil)
}
project, err := loader.LoadWithContext(ctx, configDetails,
func(o *loader.Options) {
o.SkipResolveEnvironment = true
@@ -109,21 +102,20 @@ func withComposeService(
return fmt.Errorf("failed to load the compose file: %w", err)
}
if options.EnvFilePath != "" {
// Work around compose path handling
for i, service := range project.Services {
for j, envFile := range service.EnvFiles {
if !filepath.IsAbs(envFile.Path) {
project.Services[i].EnvFiles[j].Path = filepath.Join(project.WorkingDir, envFile.Path)
}
// Work around compose path handling
for i, service := range project.Services {
for j, envFile := range service.EnvFiles {
if !filepath.IsAbs(envFile.Path) {
project.Services[i].EnvFiles[j].Path = filepath.Join(configDetails.WorkingDir, envFile.Path)
}
}
}
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
project = p
} else {
return fmt.Errorf("failed to resolve services environment: %w", err)
}
// Set the services environment variables
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
project = p
} else {
return fmt.Errorf("failed to resolve services environment: %w", err)
}
return composeFn(composeService, project)
@@ -133,7 +125,9 @@ func withComposeService(
// Deploy creates and starts containers
func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, options libstack.DeployOptions) error {
return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project)
addServiceLabels(project, false)
project = project.WithoutUnnecessaryResources()
var opts api.UpOptions
if options.ForceRecreate {
@@ -143,6 +137,10 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
opts.Create.RemoveOrphans = options.RemoveOrphans
opts.Start.CascadeStop = options.AbortOnContainerExit
if err := composeService.Build(ctx, project, api.BuildOptions{}); err != nil {
return fmt.Errorf("compose build operation failed: %w", err)
}
if err := composeService.Up(ctx, project, opts); err != nil {
return fmt.Errorf("compose up operation failed: %w", err)
}
@@ -153,14 +151,31 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
})
}
// Run runs the given service just once, without considering dependencies
func (c *ComposeDeployer) Run(ctx context.Context, filePaths []string, serviceName string, options libstack.RunOptions) error {
return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project)
addServiceLabels(project, true)
for name, service := range project.Services {
if name == serviceName {
project.DisabledServices[serviceName] = service
}
}
project.Services = make(types.Services)
if err := composeService.Create(ctx, project, api.CreateOptions{RemoveOrphans: true}); err != nil {
return fmt.Errorf("compose create operation failed: %w", err)
}
maps.Copy(project.Services, project.DisabledServices)
project.DisabledServices = make(types.Services)
opts := api.RunOptions{
AutoRemove: options.Remove,
Command: options.Args,
Detach: options.Detached,
Service: serviceName,
}
if _, err := composeService.RunOneOffContainer(ctx, project, opts); err != nil {
@@ -208,6 +223,7 @@ func (c *ComposeDeployer) Validate(ctx context.Context, filePaths []string, opti
})
}
// Config returns the compose file with the paths resolved
func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
var payload []byte
@@ -226,16 +242,47 @@ func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, option
return payload, nil
}
func addServiceLabels(project *types.Project) {
func addServiceLabels(project *types.Project, oneOff bool) {
oneOffLabel := "False"
if oneOff {
oneOffLabel = "True"
}
for i, s := range project.Services {
s.CustomLabels = map[string]string{
api.ProjectLabel: project.Name,
api.ServiceLabel: s.Name,
api.VersionLabel: api.ComposeVersion,
api.WorkingDirLabel: "/",
api.WorkingDirLabel: project.WorkingDir,
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
api.OneoffLabel: "False",
api.OneoffLabel: oneOffLabel,
}
project.Services[i] = s
}
}
func parseEnvironment(options libstack.Options) (map[string]string, error) {
env := make(map[string]string)
for _, envLine := range options.Env {
e, err := dotenv.UnmarshalWithLookup(envLine, nil)
if err != nil {
return nil, fmt.Errorf("unable to parse environment variables: %w", err)
}
maps.Copy(env, e)
}
if options.EnvFilePath == "" {
return env, nil
}
e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
if err != nil {
return nil, fmt.Errorf("unable to get the environment from the env file: %w", err)
}
maps.Copy(env, e)
return env, nil
}

View File

@@ -35,17 +35,14 @@ services:
dir := t.TempDir()
filePathOriginal, err := createFile(dir, "docker-compose.yml", composeFileContent)
require.NoError(t, err)
filePathOverride, err := createFile(dir, "docker-compose-override.yml", overrideComposeFileContent)
require.NoError(t, err)
filePathOriginal := createFile(t, dir, "docker-compose.yml", composeFileContent)
filePathOverride := createFile(t, dir, "docker-compose-override.yml", overrideComposeFileContent)
filePaths := []string{filePathOriginal, filePathOverride}
ctx := context.Background()
err = w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName})
err := w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName})
require.NoError(t, err)
err = w.Pull(ctx, filePaths, libstack.Options{ProjectName: projectName})
@@ -62,7 +59,7 @@ services:
require.True(t, containerExists(composeContainerName))
waitResult := <-w.WaitForStatus(ctx, projectName, libstack.StatusCompleted, "")
waitResult := <-w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
require.Empty(t, waitResult.ErrorMsg)
require.Equal(t, libstack.StatusCompleted, waitResult.Status)
@@ -73,14 +70,34 @@ services:
require.False(t, containerExists(composeContainerName))
}
func createFile(dir, fileName, content string) (string, error) {
func TestRun(t *testing.T) {
w := NewComposeDeployer()
filePath := createFile(t, t.TempDir(), "docker-compose.yml", `
services:
updater:
image: alpine
`)
filePaths := []string{filePath}
serviceName := "updater"
err := w.Run(context.Background(), filePaths, serviceName, libstack.RunOptions{
Remove: true,
Options: libstack.Options{
ProjectName: "project_name",
},
})
require.NoError(t, err)
}
func createFile(t *testing.T, dir, fileName, content string) string {
filePath := filepath.Join(dir, fileName)
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
return "", err
}
err := os.WriteFile(filePath, []byte(content), 0o644)
require.NoError(t, err)
return filePath, nil
return filePath
}
func containerExists(containerName string) bool {
@@ -101,8 +118,7 @@ func Test_Validate(t *testing.T) {
dir := t.TempDir()
filePathOriginal, err := createFile(dir, "docker-compose.yml", invalidComposeFileContent)
require.NoError(t, err)
filePathOriginal := createFile(t, dir, "docker-compose.yml", invalidComposeFileContent)
filePaths := []string{filePathOriginal}
@@ -110,7 +126,7 @@ func Test_Validate(t *testing.T) {
ctx := context.Background()
err = w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName})
err := w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName})
require.Error(t, err)
}
@@ -308,13 +324,11 @@ networks:
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
composeFilePath, err := createFile(dir, "docker-compose.yml", tc.composeFileContent)
require.NoError(t, err)
composeFilePath := createFile(t, dir, "docker-compose.yml", tc.composeFileContent)
envFilePath := ""
if tc.envFileContent != "" {
envFilePath, err = createFile(dir, "stack.env", tc.envFileContent)
require.NoError(t, err)
envFilePath = createFile(t, dir, "stack.env", tc.envFileContent)
}
w := NewComposeDeployer()

View File

@@ -111,7 +111,7 @@ func aggregateStatuses(services []service) (libstack.Status, string) {
}
func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status libstack.Status, _ string) <-chan libstack.WaitResult {
func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status libstack.Status) <-chan libstack.WaitResult {
waitResultCh := make(chan libstack.WaitResult)
waitResult := libstack.WaitResult{Status: status}
@@ -130,7 +130,10 @@ func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status
if err := withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
var err error
containerSummaries, err = composeService.Ps(ctx, name, api.PsOptions{All: true})
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
defer cancelFunc()
containerSummaries, err = composeService.Ps(psCtx, name, api.PsOptions{All: true})
return err
}); err != nil {

View File

@@ -66,7 +66,7 @@ func TestComposeProjectStatus(t *testing.T) {
time.Sleep(5 * time.Second)
status, statusMessage, err := waitForStatus(w, ctx, projectName, libstack.StatusRunning, "")
status, statusMessage, err := waitForStatus(w, ctx, projectName, libstack.StatusRunning)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}
@@ -86,7 +86,7 @@ func TestComposeProjectStatus(t *testing.T) {
time.Sleep(20 * time.Second)
status, statusMessage, err = waitForStatus(w, ctx, projectName, libstack.StatusRemoved, "")
status, statusMessage, err = waitForStatus(w, ctx, projectName, libstack.StatusRemoved)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}
@@ -102,11 +102,11 @@ func TestComposeProjectStatus(t *testing.T) {
}
}
func waitForStatus(deployer libstack.Deployer, ctx context.Context, stackName string, requiredStatus libstack.Status, stackFileLocation string) (libstack.Status, string, error) {
func waitForStatus(deployer libstack.Deployer, ctx context.Context, stackName string, requiredStatus libstack.Status) (libstack.Status, string, error) {
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
defer cancel()
statusCh := deployer.WaitForStatus(ctx, stackName, requiredStatus, stackFileLocation)
statusCh := deployer.WaitForStatus(ctx, stackName, requiredStatus)
result := <-statusCh
if result.ErrorMsg == "" {
return result.Status, "", nil

View File

@@ -16,7 +16,7 @@ type Deployer interface {
Pull(ctx context.Context, filePaths []string, options Options) error
Run(ctx context.Context, filePaths []string, serviceName string, options RunOptions) error
Validate(ctx context.Context, filePaths []string, options Options) error
WaitForStatus(ctx context.Context, name string, status Status, stackFileLocation string) <-chan WaitResult
WaitForStatus(ctx context.Context, name string, status Status) <-chan WaitResult
Config(ctx context.Context, filePaths []string, options Options) ([]byte, error)
}

View File

@@ -14,6 +14,9 @@ export default defineConfig({
},
bail: 2,
include: ['./app/**/*.test.ts', './app/**/*.test.tsx'],
env: {
PORTAINER_EDITION: 'CE',
},
},
plugins: [svgr({ include: /\?c$/ }), tsconfigPaths()],
});