diff --git a/Makefile b/Makefile index 1ef82293b..e1fd32d84 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ build-server: init-dist ## Build the server binary ./build/build_binary.sh "$(PLATFORM)" "$(ARCH)" build-image: build-all ## Build the Portainer image locally - docker buildx build --load -t portainerci/portainer:$(TAG) -f build/linux/Dockerfile . + docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile . build-storybook: ## Build and serve the storybook files yarn storybook:build diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 1296e1d0b..e822c5456 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -45,6 +45,7 @@ import ( "github.com/portainer/portainer/api/pendingactions" "github.com/portainer/portainer/api/pendingactions/actions" "github.com/portainer/portainer/api/pendingactions/handlers" + "github.com/portainer/portainer/api/platform" "github.com/portainer/portainer/api/scheduler" "github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/pkg/featureflags" @@ -532,7 +533,20 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatal().Msg("failed to fetch SSL settings from DB") } - upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer, kubernetesClientFactory) + platformService, err := platform.NewService(dataStore) + if err != nil { + log.Fatal().Err(err).Msg("failed initializing platform service") + } + + upgradeService, err := upgrade.NewService( + *flags.Assets, + kubernetesClientFactory, + dockerClientFactory, + composeStackManager, + dataStore, + fileService, + stackDeployer, + ) if err != nil { log.Fatal().Err(err).Msg("failed initializing upgrade service") } @@ -589,6 +603,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { UpgradeService: upgradeService, AdminCreationDone: adminCreationDone, PendingActionsService: pendingActionsService, + PlatformService: platformService, } } diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index 9ae3bacd2..c48728a3c 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -38,7 +38,7 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string { } // Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command -func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, forceRecreate bool) error { +func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeUpOptions) error { url, proxy, err := manager.fetchEndpointProxy(endpoint) if err != nil { return errors.Wrap(err, "failed to fetch environment proxy") @@ -61,7 +61,39 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta Host: url, ProjectName: stack.Name, }, - ForceRecreate: forceRecreate, + ForceRecreate: options.ForceRecreate, + AbortOnContainerExit: options.AbortOnContainerExit, + }) + return errors.Wrap(err, "failed to deploy a stack") +} + +// Run runs a one-off command on a service. Wraps `docker-compose run` command +func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, serviceName string, options portainer.ComposeRunOptions) error { + url, proxy, err := manager.fetchEndpointProxy(endpoint) + if err != nil { + return errors.Wrap(err, "failed to fetch environment proxy") + } + + if proxy != nil { + defer proxy.Close() + } + + envFilePath, err := createEnvFile(stack) + if err != nil { + return errors.Wrap(err, "failed to create env file") + } + + filePaths := stackutils.GetStackFilePaths(stack, true) + err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{ + Options: libstack.Options{ + WorkingDir: stack.ProjectPath, + EnvFilePath: envFilePath, + Host: url, + ProjectName: stack.Name, + }, + Remove: options.Remove, + Args: options.Args, + Detached: options.Detached, }) return errors.Wrap(err, "failed to deploy a stack") } diff --git a/api/exec/compose_stack_integration_test.go b/api/exec/compose_stack_integration_test.go index 9c3272a82..fb933cb65 100644 --- a/api/exec/compose_stack_integration_test.go +++ b/api/exec/compose_stack_integration_test.go @@ -60,7 +60,7 @@ func Test_UpAndDown(t *testing.T) { ctx := context.TODO() - err = w.Up(ctx, stack, endpoint, false) + err = w.Up(ctx, stack, endpoint, portainer.ComposeUpOptions{}) if err != nil { t.Fatalf("Error calling docker-compose up: %s", err) } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index 438c7231f..133dfeaf9 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -161,7 +161,7 @@ func (handler *Handler) startStack( return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint, filteredRegistries) } - return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint, false) + return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{}) case portainer.DockerSwarmStack: stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) diff --git a/api/http/handler/system/handler.go b/api/http/handler/system/handler.go index d5a9adbd9..d2e3f5485 100644 --- a/api/http/handler/system/handler.go +++ b/api/http/handler/system/handler.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/upgrade" + "github.com/portainer/portainer/api/platform" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/gorilla/mux" @@ -15,22 +16,25 @@ import ( // Handler is the HTTP handler used to handle status operations. type Handler struct { *mux.Router - status *portainer.Status - dataStore dataservices.DataStore - upgradeService upgrade.Service + status *portainer.Status + dataStore dataservices.DataStore + upgradeService upgrade.Service + platformService platform.Service } // NewHandler creates a handler to manage status operations. func NewHandler(bouncer security.BouncerService, status *portainer.Status, dataStore dataservices.DataStore, + platformService platform.Service, upgradeService upgrade.Service) *Handler { h := &Handler{ - Router: mux.NewRouter(), - dataStore: dataStore, - status: status, - upgradeService: upgradeService, + Router: mux.NewRouter(), + dataStore: dataStore, + status: status, + upgradeService: upgradeService, + platformService: platformService, } router := h.PathPrefix("/system").Subrouter() diff --git a/api/http/handler/system/system_info.go b/api/http/handler/system/system_info.go index afeda8385..ad5f944ef 100644 --- a/api/http/handler/system/system_info.go +++ b/api/http/handler/system/system_info.go @@ -42,12 +42,11 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http if endpointutils.IsEdgeEndpoint(&environment) { edgeAgents++ } - } - platform, err := plf.DetermineContainerPlatform() + platform, err := handler.platformService.GetPlatform() if err != nil { - return httperror.InternalServerError("Unable to determine container platform", err) + return httperror.InternalServerError("Failed to get platform", err) } return response.JSON(w, &systemInfoResponse{ diff --git a/api/http/handler/system/system_upgrade.go b/api/http/handler/system/system_upgrade.go index 5a9fc3bea..f881b6233 100644 --- a/api/http/handler/system/system_upgrade.go +++ b/api/http/handler/system/system_upgrade.go @@ -4,8 +4,6 @@ import ( "net/http" "regexp" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/platform" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -31,12 +29,6 @@ func (payload *systemUpgradePayload) Validate(r *http.Request) error { return nil } -var platformToEndpointType = map[platform.ContainerPlatform]portainer.EndpointType{ - platform.PlatformDockerStandalone: portainer.DockerEnvironment, - platform.PlatformDockerSwarm: portainer.DockerEnvironment, - platform.PlatformKubernetes: portainer.KubernetesLocalEnvironment, -} - // @id systemUpgrade // @summary Upgrade Portainer to BE // @description Upgrade Portainer to BE @@ -51,40 +43,20 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h return httperror.BadRequest("Invalid request payload", err) } - environment, err := handler.guessLocalEndpoint() + environment, err := handler.platformService.GetLocalEnvironment() if err != nil { - return httperror.InternalServerError("Failed to guess local endpoint", err) + return httperror.InternalServerError("Failed to get local environment", err) } - err = handler.upgradeService.Upgrade(environment, payload.License) + platform, err := handler.platformService.GetPlatform() + if err != nil { + return httperror.InternalServerError("Failed to get platform", err) + } + + err = handler.upgradeService.Upgrade(platform, environment, payload.License) if err != nil { return httperror.InternalServerError("Failed to upgrade Portainer", err) } return response.Empty(w) } - -func (handler *Handler) guessLocalEndpoint() (*portainer.Endpoint, error) { - platform, err := platform.DetermineContainerPlatform() - if err != nil { - return nil, errors.Wrap(err, "failed to determine container platform") - } - - endpointType, ok := platformToEndpointType[platform] - if !ok { - return nil, errors.New("failed to determine endpoint type") - } - - endpoints, err := handler.dataStore.Endpoint().Endpoints() - if err != nil { - return nil, errors.Wrap(err, "failed to retrieve endpoints") - } - - for _, endpoint := range endpoints { - if endpoint.Type == endpointType { - return &endpoint, nil - } - } - - return nil, errors.New("failed to find local endpoint") -} diff --git a/api/http/handler/system/version_test.go b/api/http/handler/system/version_test.go index cee3f4015..6df987408 100644 --- a/api/http/handler/system/version_test.go +++ b/api/http/handler/system/version_test.go @@ -39,7 +39,7 @@ func Test_getSystemVersion(t *testing.T) { apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User()) requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService) - h := NewHandler(requestBouncer, &portainer.Status{}, store, nil) + h := NewHandler(requestBouncer, &portainer.Status{}, store, nil, nil) // generate standard and admin user tokens jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role}) diff --git a/api/http/server.go b/api/http/server.go index 83355793b..9fc55757f 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -65,6 +65,7 @@ import ( k8s "github.com/portainer/portainer/api/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/pendingactions" + "github.com/portainer/portainer/api/platform" "github.com/portainer/portainer/api/scheduler" "github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/pkg/libhelm" @@ -111,6 +112,7 @@ type Server struct { UpgradeService upgrade.Service AdminCreationDone chan struct{} PendingActionsService *pendingactions.PendingActionsService + PlatformService platform.Service } // Start starts the HTTP server @@ -265,6 +267,7 @@ func (server *Server) Start() error { var systemHandler = system.NewHandler(requestBouncer, server.Status, server.DataStore, + server.PlatformService, server.UpgradeService) var templatesHandler = templates.NewHandler(requestBouncer) diff --git a/api/internal/testhelpers/compose_stack_manager.go b/api/internal/testhelpers/compose_stack_manager.go index 0e2a6f84b..ce0020c6c 100644 --- a/api/internal/testhelpers/compose_stack_manager.go +++ b/api/internal/testhelpers/compose_stack_manager.go @@ -19,8 +19,11 @@ func (manager *composeStackManager) ComposeSyntaxMaxVersion() string { func (manager *composeStackManager) NormalizeStackName(name string) string { return name } +func (manager *composeStackManager) Run(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, serviceName string, options portainer.ComposeRunOptions) error { + return nil +} -func (manager *composeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, forceRecreate bool) error { +func (manager *composeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeUpOptions) error { return nil } diff --git a/api/internal/upgrade/upgrade.go b/api/internal/upgrade/upgrade.go index c6b21f719..472875d57 100644 --- a/api/internal/upgrade/upgrade.go +++ b/api/internal/upgrade/upgrade.go @@ -3,11 +3,13 @@ package upgrade import ( "fmt" - "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/kubernetes/cli" - "github.com/portainer/portainer/api/platform" - "github.com/portainer/portainer/pkg/libstack" + "github.com/portainer/portainer/api/dataservices" + dockerclient "github.com/portainer/portainer/api/docker/client" + kubecli "github.com/portainer/portainer/api/kubernetes/cli" + plf "github.com/portainer/portainer/api/platform" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/rs/zerolog/log" ) const ( @@ -26,48 +28,54 @@ const ( ) type Service interface { - Upgrade(environment *portainer.Endpoint, licenseKey string) error + Upgrade(platform plf.ContainerPlatform, environment *portainer.Endpoint, licenseKey string) error } type service struct { - composeDeployer libstack.Deployer - kubernetesClientFactory *cli.ClientFactory + kubernetesClientFactory *kubecli.ClientFactory + dockerClientFactory *dockerclient.ClientFactory + dockerComposeStackManager portainer.ComposeStackManager + fileService portainer.FileService isUpdating bool - platform platform.ContainerPlatform assetsPath string } func NewService( assetsPath string, - composeDeployer libstack.Deployer, - kubernetesClientFactory *cli.ClientFactory, + kubernetesClientFactory *kubecli.ClientFactory, + dockerClientFactory *dockerclient.ClientFactory, + dockerComposeStackManager portainer.ComposeStackManager, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer, ) (Service, error) { - platform, err := platform.DetermineContainerPlatform() - if err != nil { - return nil, errors.Wrap(err, "failed to determine container platform") - } return &service{ - assetsPath: assetsPath, - composeDeployer: composeDeployer, - kubernetesClientFactory: kubernetesClientFactory, - platform: platform, + assetsPath: assetsPath, + kubernetesClientFactory: kubernetesClientFactory, + dockerClientFactory: dockerClientFactory, + dockerComposeStackManager: dockerComposeStackManager, + fileService: fileService, }, nil } -func (service *service) Upgrade(environment *portainer.Endpoint, licenseKey string) error { +func (service *service) Upgrade(platform plf.ContainerPlatform, environment *portainer.Endpoint, licenseKey string) error { service.isUpdating = true + log.Debug(). + Str("platform", string(platform)). + Msg("Starting upgrade process") - switch service.platform { - case platform.PlatformDockerStandalone: - return service.upgradeDocker(licenseKey, portainer.APIVersion, "standalone") - case platform.PlatformDockerSwarm: - return service.upgradeDocker(licenseKey, portainer.APIVersion, "swarm") - case platform.PlatformKubernetes: + switch platform { + case plf.PlatformDockerStandalone: + return service.upgradeDocker(environment, licenseKey, portainer.APIVersion, "standalone") + case plf.PlatformDockerSwarm: + return service.upgradeDocker(environment, licenseKey, portainer.APIVersion, "swarm") + case plf.PlatformKubernetes: return service.upgradeKubernetes(environment, licenseKey, portainer.APIVersion) } - return fmt.Errorf("unsupported platform %s", service.platform) + service.isUpdating = false + return fmt.Errorf("unsupported platform %s", platform) } diff --git a/api/internal/upgrade/upgrade_docker.go b/api/internal/upgrade/upgrade_docker.go index b399dc054..f7f4ecc59 100644 --- a/api/internal/upgrade/upgrade_docker.go +++ b/api/internal/upgrade/upgrade_docker.go @@ -1,26 +1,25 @@ package upgrade import ( - "bytes" "context" "fmt" "os" "strings" "time" - "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/client" + "github.com/docker/docker/api/types/image" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" - "github.com/portainer/portainer/pkg/libstack" "github.com/cbroglie/mustache" "github.com/pkg/errors" "github.com/rs/zerolog/log" ) -func (service *service) upgradeDocker(licenseKey, version, envType string) error { +func (service *service) upgradeDocker(environment *portainer.Endpoint, licenseKey, version string, envType string) error { ctx := context.TODO() + templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile) portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) @@ -30,15 +29,16 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) - skipPullImage := os.Getenv(skipPullImageEnvVar) + skipPullImageEnv := os.Getenv(skipPullImageEnvVar) + skipPullImage := skipPullImageEnv != "" - if err := service.checkImageForDocker(ctx, image, skipPullImage != ""); err != nil { + if err := service.checkImageForDocker(ctx, environment, image, skipPullImage); err != nil { return err } composeFile, err := mustache.RenderFile(templateName, map[string]string{ "image": image, - "skip_pull_image": skipPullImage, + "skip_pull_image": skipPullImageEnv, "updater_image": os.Getenv(updaterImageEnvVar), "license": licenseKey, "envType": envType, @@ -52,13 +52,10 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error return errors.Wrap(err, "failed to render upgrade template") } - tmpDir := os.TempDir() timeId := time.Now().Unix() - filePath := filesystem.JoinPaths(tmpDir, fmt.Sprintf("upgrade-%d.yml", timeId)) + fileName := fmt.Sprintf("upgrade-%d.yml", timeId) - r := bytes.NewReader([]byte(composeFile)) - - err = filesystem.CreateFile(filePath, r) + filePath, err := service.fileService.StoreStackFileFromBytes("upgrade", fileName, []byte(composeFile)) if err != nil { return errors.Wrap(err, "failed to create upgrade compose file") } @@ -66,42 +63,37 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error projectName := fmt.Sprintf( "portainer-upgrade-%d-%s", timeId, - strings.ReplaceAll(version, ".", "-")) - - err = service.composeDeployer.Deploy( - ctx, - []string{filePath}, - libstack.DeployOptions{ - ForceRecreate: true, - AbortOnContainerExit: true, - Options: libstack.Options{ - ProjectName: projectName, - }, - }, + strings.ReplaceAll(version, ".", "-"), ) - // optimally, server was restarted by the updater, so we should not reach this point + tempStack := &portainer.Stack{ + Name: projectName, + ProjectPath: filePath, + EntryPoint: fileName, + } + + err = service.dockerComposeStackManager.Run(ctx, tempStack, environment, "updater", portainer.ComposeRunOptions{ + Remove: true, + Detached: true, + }) if err != nil { return errors.Wrap(err, "failed to deploy upgrade stack") } - return errors.New("upgrade failed: server should have been restarted by the updater") + return nil } -func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error { - cli, err := client.NewClientWithOpts( - client.FromEnv, - client.WithAPIVersionNegotiation(), - ) +func (service *service) checkImageForDocker(ctx context.Context, environment *portainer.Endpoint, imageName string, skipPullImage bool) error { + cli, err := service.dockerClientFactory.CreateClient(environment, "", nil) if err != nil { return errors.Wrap(err, "failed to create docker client") } if skipPullImage { filters := filters.NewArgs() - filters.Add("reference", image) - images, err := cli.ImageList(ctx, types.ImageListOptions{ + filters.Add("reference", imageName) + images, err := cli.ImageList(ctx, image.ListOptions{ Filters: filters, }) if err != nil { @@ -109,15 +101,15 @@ func (service *service) checkImageForDocker(ctx context.Context, image string, s } if len(images) == 0 { - return errors.Errorf("image %s not found locally", image) + return errors.Errorf("image %s not found locally", imageName) } return nil } else { // check if available on registry - _, err := cli.DistributionInspect(ctx, image, "") + _, err := cli.DistributionInspect(ctx, imageName, "") if err != nil { - return errors.Errorf("image %s not found on registry", image) + return errors.Errorf("image %s not found on registry", imageName) } return nil diff --git a/api/platform/platform.go b/api/platform/platform.go index 68aa1bb79..3da4b9075 100644 --- a/api/platform/platform.go +++ b/api/platform/platform.go @@ -1,14 +1,7 @@ package platform import ( - "context" "os" - - dockerclient "github.com/portainer/portainer/api/docker/client" - - "github.com/docker/docker/client" - "github.com/pkg/errors" - "github.com/rs/zerolog/log" ) const ( @@ -20,6 +13,8 @@ const ( type ContainerPlatform string const ( + // PlatformDocker represent the Docker platform (Unknown) + PlatformDocker = ContainerPlatform("Docker") // PlatformDockerStandalone represent the Docker platform (Standalone) PlatformDockerStandalone = ContainerPlatform("Docker Standalone") // PlatformDockerSwarm represent the Docker platform (Swarm) @@ -34,43 +29,22 @@ const ( // or KUBERNETES_SERVICE_HOST environment variable to determine if // the container is running on Podman or inside the Kubernetes platform. // Defaults to Docker otherwise. -func DetermineContainerPlatform() (ContainerPlatform, error) { +func DetermineContainerPlatform() ContainerPlatform { podmanModeEnvVar := os.Getenv(PodmanMode) if podmanModeEnvVar == "1" { - return PlatformPodman, nil + return PlatformPodman } serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost) if serviceHostKubernetesEnvVar != "" { - return PlatformKubernetes, nil + return PlatformKubernetes } if !isRunningInContainer() { - return "", nil + return "" } - dockerCli, err := dockerclient.CreateSimpleClient() - if err != nil { - return "", errors.WithMessage(err, "failed to create docker client") - } - defer dockerCli.Close() - - info, err := dockerCli.Info(context.Background()) - if err != nil { - if client.IsErrConnectionFailed(err) { - log.Warn().Err(err).Msg("failed to retrieve docker info") - - return "", nil - } - - return "", errors.WithMessage(err, "failed to retrieve docker info") - } - - if info.Swarm.NodeID == "" { - return PlatformDockerStandalone, nil - } - - return PlatformDockerSwarm, nil + return PlatformDocker } // isRunningInContainer returns true if the process is running inside a container diff --git a/api/platform/service.go b/api/platform/service.go new file mode 100644 index 000000000..c8efade7a --- /dev/null +++ b/api/platform/service.go @@ -0,0 +1,113 @@ +package platform + +import ( + "errors" + "fmt" + "slices" + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/rs/zerolog/log" +) + +type Service interface { + GetLocalEnvironment() (*portainer.Endpoint, error) + GetPlatform() (ContainerPlatform, error) +} + +type service struct { + dataStore dataservices.DataStore + environment *portainer.Endpoint + platform ContainerPlatform +} + +func NewService(dataStore dataservices.DataStore) (Service, error) { + + return &service{ + dataStore: dataStore, + }, nil +} + +func (service *service) GetLocalEnvironment() (*portainer.Endpoint, error) { + if service.environment == nil { + environment, platform, err := guessLocalEnvironment(service.dataStore) + if err != nil { + return nil, err + } + + service.environment = environment + service.platform = platform + } + + return service.environment, nil +} + +func (service *service) GetPlatform() (ContainerPlatform, error) { + if service.environment == nil { + environment, platform, err := guessLocalEnvironment(service.dataStore) + if err != nil { + return "", err + } + + service.environment = environment + service.platform = platform + } + + return service.platform, nil +} + +var platformToEndpointType = map[ContainerPlatform][]portainer.EndpointType{ + PlatformDocker: {portainer.AgentOnDockerEnvironment, portainer.DockerEnvironment}, + PlatformKubernetes: {portainer.KubernetesLocalEnvironment}, +} + +func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) { + platform := DetermineContainerPlatform() + + if !slices.Contains([]ContainerPlatform{PlatformDocker, PlatformKubernetes}, platform) { + log.Debug(). + Str("platform", string(platform)). + Msg("environment not supported for upgrade") + + return nil, "", nil + } + + endpoints, err := dataStore.Endpoint().Endpoints() + if err != nil { + return nil, "", fmt.Errorf("failed to retrieve endpoints: %w", err) + } + + endpointTypes, ok := platformToEndpointType[platform] + if !ok { + return nil, "", errors.New("failed to determine endpoint type") + } + + for _, endpoint := range endpoints { + if slices.Contains(endpointTypes, endpoint.Type) { + if platform != PlatformDocker { + return &endpoint, platform, nil + } + + dockerPlatform := checkDockerEnvTypeForUpgrade(&endpoint) + if dockerPlatform != "" { + return &endpoint, dockerPlatform, nil + } + } + } + + return nil, "", errors.New("failed to find local endpoint") +} + +func checkDockerEnvTypeForUpgrade(environment *portainer.Endpoint) ContainerPlatform { + if endpointutils.IsLocalEndpoint(environment) { // standalone + return PlatformDockerStandalone + } + + if strings.HasPrefix(environment.URL, "tcp://tasks.") { // swarm + return PlatformDockerSwarm + } + + return "" +} diff --git a/api/portainer.go b/api/portainer.go index 5841a7901..91a77a44b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1357,11 +1357,31 @@ type ( ValidateFlags(flags *CLIFlags) error } + ComposeUpOptions struct { + // ForceRecreate forces to recreate containers + ForceRecreate bool + // AbortOnContainerExit will stop the deployment if a container exits. + // This is useful when running a onetime task. + // + // When this is set, docker compose will output its logs to stdout + AbortOnContainerExit bool + } + + ComposeRunOptions struct { + // Remove will remove the container after it has stopped + Remove bool + // Args are the arguments to pass to the container + Args []string + // Detached will run the container in the background + Detached bool + } + // ComposeStackManager represents a service to manage Compose stacks ComposeStackManager interface { ComposeSyntaxMaxVersion() string NormalizeStackName(name string) string - Up(ctx context.Context, stack *Stack, endpoint *Endpoint, forceRecreate bool) error + 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 } diff --git a/api/stacks/deployments/deployer.go b/api/stacks/deployments/deployer.go index b3d37608b..ac5df2726 100644 --- a/api/stacks/deployments/deployer.go +++ b/api/stacks/deployments/deployer.go @@ -69,7 +69,9 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por } } - err := d.composeStackManager.Up(context.TODO(), stack, endpoint, forceRecreate) + err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{ + ForceRecreate: forceRecreate, + }) if err != nil { d.composeStackManager.Down(context.TODO(), stack, endpoint) } diff --git a/api/stacks/deployments/deployment_compose_config.go b/api/stacks/deployments/deployment_compose_config.go index 199928a87..95b1ae804 100644 --- a/api/stacks/deployments/deployment_compose_config.go +++ b/api/stacks/deployments/deployment_compose_config.go @@ -2,7 +2,8 @@ package deployments import ( "fmt" - "log" + + "github.com/rs/zerolog/log" "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" @@ -24,6 +25,7 @@ type ComposeStackDeploymentConfig struct { } func CreateComposeStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) { + user, err := dataStore.User().Read(securityContext.UserID) if err != nil { return nil, fmt.Errorf("unable to load user information from the database: %w", err) @@ -60,7 +62,7 @@ func (config *ComposeStackDeploymentConfig) GetUsername() string { func (config *ComposeStackDeploymentConfig) Deploy() error { if config.FileService == nil || config.StackDeployer == nil { - log.Println("[deployment, compose] file service or stack deployer is not initialised") + log.Debug().Msg("file service or stack deployer is not initialized") return errors.New("file service or stack deployer cannot be nil") } diff --git a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx index 91af98140..b67300d90 100644 --- a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx +++ b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx @@ -59,7 +59,7 @@ function UpgradeBEBanner() { if ( !enabledPlatforms.includes(systemInfo.platform) && - process.env.NODE_ENV !== 'development' + process.env.FORCE_SHOW_UPGRADE_BANNER !== '' ) { return null; } diff --git a/pkg/libstack/compose/internal/composeplugin/composeplugin.go b/pkg/libstack/compose/internal/composeplugin/composeplugin.go index ecd313b7d..70eb28ba8 100644 --- a/pkg/libstack/compose/internal/composeplugin/composeplugin.go +++ b/pkg/libstack/compose/internal/composeplugin/composeplugin.go @@ -158,11 +158,12 @@ func (wrapper *PluginWrapper) command(command composeCommand, options libstack.O cmd.Env = append(cmd.Env, options.Env...) + executedCommand := cmd.String() + log.Debug(). - Str("command", program). - Strs("args", args). + Str("command", executedCommand). Interface("env", cmd.Env). - Msg("run command") + Msg("execute command") cmd.Stderr = &stderr diff --git a/pkg/libstack/compose/internal/composeplugin/run_cmd.go b/pkg/libstack/compose/internal/composeplugin/run_cmd.go new file mode 100644 index 000000000..d883a6bb3 --- /dev/null +++ b/pkg/libstack/compose/internal/composeplugin/run_cmd.go @@ -0,0 +1,53 @@ +package composeplugin + +import ( + "context" + + "github.com/portainer/portainer/pkg/libstack" + "github.com/rs/zerolog/log" +) + +func (wrapper *PluginWrapper) Run(ctx context.Context, filePaths []string, serviceName string, options libstack.RunOptions) error { + + output, err := wrapper.command(newRunCommand(filePaths, serviceName, runOptions{ + remove: options.Remove, + args: options.Args, + detached: options.Detached, + }), options.Options) + if len(output) != 0 { + if err != nil { + return err + } + + log.Info().Msg("Stack run successful") + + log.Debug(). + Str("output", string(output)). + Msg("docker compose") + } + + return err +} + +type runOptions struct { + remove bool + args []string + detached bool +} + +func newRunCommand(filePaths []string, serviceName string, options runOptions) composeCommand { + args := []string{"run"} + + if options.remove { + args = append(args, "--rm") + } + + if options.detached { + args = append(args, "-d") + } + + args = append(args, serviceName) + args = append(args, options.args...) + + return newCommand(args, filePaths) +} diff --git a/pkg/libstack/libstack.go b/pkg/libstack/libstack.go index e417e26d9..32cba1101 100644 --- a/pkg/libstack/libstack.go +++ b/pkg/libstack/libstack.go @@ -12,6 +12,7 @@ type Deployer interface { // if projectName is supplied filePaths will be ignored Remove(ctx context.Context, projectName string, filePaths []string, options Options) error 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) <-chan WaitResult Config(ctx context.Context, filePaths []string, options Options) ([]byte, error) @@ -61,3 +62,13 @@ type DeployOptions struct { // When this is set, docker compose will output its logs to stdout AbortOnContainerExit bool `` } + +type RunOptions struct { + Options + // Automatically remove the container when it exits + Remove bool + // A list of arguments to pass to the container + Args []string + // Run the container in detached mode + Detached bool +}