diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37eb771df..ad3e449d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ $ yarn start Portainer can now be accessed at . -Find more detailed steps at . +Find more detailed steps at . ### Build customisation @@ -103,6 +103,10 @@ You can customise the following settings: - `PORTAINER_PROJECT`: The root dir of the repository - `${portainerRoot}/dist/` is imported into the container to get the build artifacts and external tools (defaults to `your current dir`). - `PORTAINER_FLAGS`: a list of flags to be used on the portainer commandline, in the form `--admin-password= --feat fdo=false --feat open-amt` (default: `""`). +## Testing your build + +The `--log-level=DEBUG` flag can be passed to the Portainer container in order to provide additional debug output which may be useful when troubleshooting your builds. Please note that this flag was originally intended for internal use and as such the format, functionality and output may change between releases without warning. + ## Adding api docs When adding a new resource (or a route handler), we should add a new tag to api/http/handler/handler.go#L136 like this: diff --git a/api/agent/version.go b/api/agent/version.go index bdb7ebce1..03d3cf4f6 100644 --- a/api/agent/version.go +++ b/api/agent/version.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "errors" "fmt" + "io" "net/http" "strconv" "time" @@ -42,7 +43,9 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port if err != nil { return 0, "", err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() if resp.StatusCode != http.StatusNoContent { return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode) diff --git a/api/cli/cli.go b/api/cli/cli.go index c04f41121..5f7062c21 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -72,6 +72,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { if err != nil { panic(err) } + *flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets) } @@ -80,7 +81,6 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { // ValidateFlags validates the values of the flags. func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { - displayDeprecationWarnings(flags) err := validateEndpointURL(*flags.EndpointURL) @@ -111,31 +111,38 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) { } func validateEndpointURL(endpointURL string) error { - if endpointURL != "" { - if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") { - return errInvalidEndpointProtocol - } + if endpointURL == "" { + return nil + } - if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") { - socketPath := strings.TrimPrefix(endpointURL, "unix://") - socketPath = strings.TrimPrefix(socketPath, "npipe://") - if _, err := os.Stat(socketPath); err != nil { - if os.IsNotExist(err) { - return errSocketOrNamedPipeNotFound - } - return err + if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") { + return errInvalidEndpointProtocol + } + + if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") { + socketPath := strings.TrimPrefix(endpointURL, "unix://") + socketPath = strings.TrimPrefix(socketPath, "npipe://") + if _, err := os.Stat(socketPath); err != nil { + if os.IsNotExist(err) { + return errSocketOrNamedPipeNotFound } + + return err } } + return nil } func validateSnapshotInterval(snapshotInterval string) error { - if snapshotInterval != "" { - _, err := time.ParseDuration(snapshotInterval) - if err != nil { - return errInvalidSnapshotInterval - } + if snapshotInterval == "" { + return nil } + + _, err := time.ParseDuration(snapshotInterval) + if err != nil { + return errInvalidSnapshotInterval + } + return nil } diff --git a/api/cli/confirm.go b/api/cli/confirm.go index ac468e97e..ec1076057 100644 --- a/api/cli/confirm.go +++ b/api/cli/confirm.go @@ -12,13 +12,14 @@ func Confirm(message string) (bool, error) { fmt.Printf("%s [y/N]", message) reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') if err != nil { return false, err } - answer = strings.Replace(answer, "\n", "", -1) + + answer = strings.ReplaceAll(answer, "\n", "") answer = strings.ToLower(answer) return answer == "y" || answer == "yes", nil - } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 9a0285f0d..c3479cac4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -684,25 +684,21 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatal().Msg("failed to fetch SSL settings from DB") } - upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer) + upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer, kubernetesClientFactory) if err != nil { log.Fatal().Err(err).Msg("failed initializing upgrade service") } - // FIXME: In 2.16 we changed the way ingress controller permissions are - // stored. Instead of being stored as annotation on an ingress rule, we keep - // them in our database. However, in order to run the migration we need an - // admin kube client to run lookup the old ingress rules and compare them - // with the current existing ingress classes. - // - // Unfortunately, our migrations run as part of the database initialization - // and our kubeclients require an initialized database. So it is not - // possible to do this migration as part of our normal flow. We DO have a - // migration which toggles a boolean in kubernetes configuration that - // indicated that this "post init" migration should be run. If/when this is - // resolved we can remove this function. - err = kubernetesClientFactory.PostInitMigrateIngresses() - if err != nil { + // Our normal migrations run as part of the database initialization + // but some more complex migrations require access to a kubernetes or docker + // client. Therefore we run a separate migration process just before + // starting the server. + postInitMigrator := datastore.NewPostInitMigrator( + kubernetesClientFactory, + dockerClientFactory, + dataStore, + ) + if err := postInitMigrator.PostInitMigrate(); err != nil { log.Fatal().Err(err).Msg("failure during post init migrations") } diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go index 1cd119fce..bbf839324 100644 --- a/api/crypto/ecdsa.go +++ b/api/crypto/ecdsa.go @@ -7,7 +7,6 @@ import ( "crypto/x509" "encoding/base64" "encoding/hex" - "math/big" "github.com/portainer/libcrypto" ) @@ -115,9 +114,6 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) { hash := libcrypto.HashFromBytes([]byte(message)) - r := big.NewInt(0) - s := big.NewInt(0) - r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash) if err != nil { return "", err diff --git a/api/database/boltdb/json_test.go b/api/database/boltdb/json_test.go index 2d22bfafc..3d6d83ce1 100644 --- a/api/database/boltdb/json_test.go +++ b/api/database/boltdb/json_test.go @@ -129,7 +129,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) { var object string err := conn.UnmarshalObject(test.object, &object) is.NoError(err) - is.Equal(test.expected, string(object)) + is.Equal(test.expected, object) }) } } diff --git a/api/database/boltdb/tx.go b/api/database/boltdb/tx.go index e62e7b2d9..c4503fde8 100644 --- a/api/database/boltdb/tx.go +++ b/api/database/boltdb/tx.go @@ -92,7 +92,7 @@ func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, i return err } - return bucket.Put(tx.conn.ConvertToKey(int(id)), data) + return bucket.Put(tx.conn.ConvertToKey(id), data) } func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj interface{}) error { diff --git a/api/database/database.go b/api/database/database.go index f4705cc94..492d56bcf 100644 --- a/api/database/database.go +++ b/api/database/database.go @@ -9,8 +9,7 @@ import ( // NewDatabase should use config options to return a connection to the requested database func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) { - switch storeType { - case "boltdb": + if storeType == "boltdb" { return &boltdb.DbConnection{ Path: storePath, EncryptionKey: encryptionKey, diff --git a/api/datastore/migrate_post_init.go b/api/datastore/migrate_post_init.go new file mode 100644 index 000000000..2f943c193 --- /dev/null +++ b/api/datastore/migrate_post_init.go @@ -0,0 +1,117 @@ +package datastore + +import ( + "context" + + "github.com/docker/docker/api/types" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/docker" + "github.com/portainer/portainer/api/kubernetes/cli" + + "github.com/rs/zerolog/log" +) + +type PostInitMigrator struct { + kubeFactory *cli.ClientFactory + dockerFactory *docker.ClientFactory + dataStore dataservices.DataStore +} + +func NewPostInitMigrator(kubeFactory *cli.ClientFactory, dockerFactory *docker.ClientFactory, dataStore dataservices.DataStore) *PostInitMigrator { + return &PostInitMigrator{ + kubeFactory: kubeFactory, + dockerFactory: dockerFactory, + dataStore: dataStore, + } +} + +func (migrator *PostInitMigrator) PostInitMigrate() error { + if err := migrator.PostInitMigrateIngresses(); err != nil { + return err + } + + migrator.PostInitMigrateGPUs() + + return nil +} + +func (migrator *PostInitMigrator) PostInitMigrateIngresses() error { + endpoints, err := migrator.dataStore.Endpoint().Endpoints() + if err != nil { + return err + } + + for i := range endpoints { + // Early exit if we do not need to migrate! + if !endpoints[i].PostInitMigrations.MigrateIngresses { + return nil + } + + err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i]) + if err != nil { + log.Debug().Err(err).Msg("failure migrating endpoint ingresses") + } + } + + return nil +} + +// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found +// If there's an error getting the containers, we'll log it and move on +func (migrator *PostInitMigrator) PostInitMigrateGPUs() { + environments, err := migrator.dataStore.Endpoint().Endpoints() + if err != nil { + log.Err(err).Msg("failure getting endpoints") + return + } + + for i := range environments { + if environments[i].Type == portainer.DockerEnvironment { + // // Early exit if we do not need to migrate! + if !environments[i].PostInitMigrations.MigrateGPUs { + return + } + + // set the MigrateGPUs flag to false so we don't run this again + environments[i].PostInitMigrations.MigrateGPUs = false + migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i]) + + // create a docker client + dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil) + if err != nil { + log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name) + return + } + defer dockerClient.Close() + + // get all containers + containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true}) + if err != nil { + log.Err(err).Msg("failed to list containers") + return + } + + // check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint + containersLoop: + for _, container := range containers { + // https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs + containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID) + if err != nil { + log.Err(err).Msg("failed to inspect container") + return + } + + deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests + for _, deviceRequest := range deviceRequests { + if deviceRequest.Driver == "nvidia" { + environments[i].EnableGPUManagement = true + migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i]) + + break containersLoop + } + } + } + } + } +} diff --git a/api/datastore/migrator/migrate_dbversion90.go b/api/datastore/migrator/migrate_dbversion90.go index 4d890a40a..fe107188d 100644 --- a/api/datastore/migrator/migrate_dbversion90.go +++ b/api/datastore/migrator/migrate_dbversion90.go @@ -3,11 +3,16 @@ package migrator import ( "github.com/rs/zerolog/log" + portainer "github.com/portainer/portainer/api" portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" ) func (m *Migrator) migrateDBVersionToDB90() error { - if err := m.updateUserThemForDB90(); err != nil { + if err := m.updateUserThemeForDB90(); err != nil { + return err + } + + if err := m.updateEnableGpuManagementFeatures(); err != nil { return err } @@ -39,7 +44,7 @@ func (m *Migrator) updateEdgeStackStatusForDB90() error { return nil } -func (m *Migrator) updateUserThemForDB90() error { +func (m *Migrator) updateUserThemeForDB90() error { log.Info().Msg("updating existing user theme settings") users, err := m.userService.Users() @@ -60,3 +65,28 @@ func (m *Migrator) updateUserThemForDB90() error { return nil } + +func (m *Migrator) updateEnableGpuManagementFeatures() error { + // get all environments + environments, err := m.endpointService.Endpoints() + if err != nil { + return err + } + + for _, environment := range environments { + if environment.Type == portainer.DockerEnvironment { + // set the PostInitMigrations.MigrateGPUs to true on this environment to run the migration only on the 2.18 upgrade + environment.PostInitMigrations.MigrateGPUs = true + // if there's one or more gpu, set the EnableGpuManagement setting to true + gpuList := environment.Gpus + if len(gpuList) > 0 { + environment.EnableGPUManagement = true + } + // update the environment + if err := m.endpointService.UpdateEndpoint(environment.ID, &environment); err != nil { + return err + } + } + } + return nil +} diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index a4d4b2ac8..9ef6f3d01 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -46,6 +46,7 @@ }, "EdgeCheckinInterval": 0, "EdgeKey": "", + "EnableGPUManagement": false, "Gpus": [], "GroupId": 1, "Id": 1, @@ -63,6 +64,7 @@ "UseServerMetrics": false }, "Flags": { + "IsServerIngressClassDetected": false, "IsServerMetricsDetected": false, "IsServerStorageDetected": false }, @@ -71,6 +73,7 @@ "LastCheckInDate": 0, "Name": "local", "PostInitMigrations": { + "MigrateGPUs": true, "MigrateIngresses": true }, "PublicURL": "", @@ -903,8 +906,7 @@ }, "Role": 1, "ThemeSettings": { - "color": "", - "subtleUpgradeButton": false + "color": "" }, "TokenIssueAt": 0, "UserTheme": "", @@ -934,8 +936,7 @@ }, "Role": 1, "ThemeSettings": { - "color": "", - "subtleUpgradeButton": false + "color": "" }, "TokenIssueAt": 0, "UserTheme": "", @@ -943,6 +944,6 @@ } ], "version": { - "VERSION": "{\"SchemaVersion\":\"2.18.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" + "VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" } } \ No newline at end of file diff --git a/api/docker/client.go b/api/docker/client.go index 909fbe708..af9161b17 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -38,17 +38,19 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers // with an agent enabled environment(endpoint) to target a specific node in an agent cluster. // The underlying http client timeout may be specified, a default value is used otherwise. func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) { - if endpoint.Type == portainer.AzureEnvironment { + switch endpoint.Type { + case portainer.AzureEnvironment: return nil, errUnsupportedEnvironmentType - } else if endpoint.Type == portainer.AgentOnDockerEnvironment { + case portainer.AgentOnDockerEnvironment: return createAgentClient(endpoint, factory.signatureService, nodeName, timeout) - } else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { + case portainer.EdgeAgentOnDockerEnvironment: return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout) } if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { return createLocalClient(endpoint) } + return createTCPClient(endpoint, timeout) } diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index dd84bd6a2..bd9985cd4 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -82,9 +82,11 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S } err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{ + WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, Host: url, }) + return errors.Wrap(err, "failed to remove a stack") } diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index 114ac3fd4..b16cfe11a 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -59,6 +59,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin if err != nil { return err } + for _, registry := range registries { if registry.Authentication { err = registryutils.EnsureRegTokenValid(manager.dataStore, ®istry) @@ -75,6 +76,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin runCommandAndCaptureStdErr(command, registryArgs, nil, "") } } + return nil } @@ -84,7 +86,9 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { if err != nil { return err } + args = append(args, "logout") + return runCommandAndCaptureStdErr(command, args, nil, "") } @@ -101,6 +105,7 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pul } else { args = append(args, "stack", "deploy", "--with-registry-auth") } + if !pullImage { args = append(args, "--resolve-image=never") } @@ -112,6 +117,7 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pul for _, envvar := range stack.Env { env = append(env, envvar.Name+"="+envvar.Value) } + return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath) } @@ -121,7 +127,9 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta if err != nil { return err } + args = append(args, "stack", "rm", stack.Name) + return runCommandAndCaptureStdErr(command, args, nil, "") } @@ -198,6 +206,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string if config["HttpHeaders"] == nil { config["HttpHeaders"] = make(map[string]interface{}) } + headersObject := config["HttpHeaders"].(map[string]interface{}) headersObject["X-PortainerAgent-ManagerOperation"] = "1" headersObject["X-PortainerAgent-Signature"] = signature @@ -230,5 +239,6 @@ func configureFilePaths(args []string, filePaths []string) []string { for _, path := range filePaths { args = append(args, "--compose-file", path) } + return args } diff --git a/api/git/azure.go b/api/git/azure.go index 7bbe65c64..72d35802c 100644 --- a/api/git/azure.go +++ b/api/git/azure.go @@ -75,6 +75,7 @@ func newHttpClientForAzure() *http.Client { } client.InstallProtocol("https", githttp.NewClient(httpsCli)) + return httpsCli } @@ -98,10 +99,12 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO if err != nil { return "", errors.WithMessage(err, "failed to parse url") } + downloadUrl, err := a.buildDownloadUrl(config, opt.referenceName) if err != nil { return "", errors.WithMessage(err, "failed to build download url") } + zipFile, err := os.CreateTemp("", "azure-git-repo-*.zip") if err != nil { return "", errors.WithMessage(err, "failed to create temp file") @@ -133,6 +136,7 @@ func (a *azureClient) downloadZipFromAzureDevOps(ctx context.Context, opt cloneO if err != nil { return "", errors.WithMessage(err, "failed to save HTTP response to a file") } + return zipFile.Name(), nil } @@ -141,6 +145,7 @@ func (a *azureClient) latestCommitID(ctx context.Context, opt fetchOption) (stri if err != nil { return "", err } + return rootItem.CommitId, nil } @@ -187,6 +192,7 @@ func (a *azureClient) getRootItem(ctx context.Context, opt fetchOption) (*azureI if len(items.Value) == 0 || items.Value[0].CommitId == "" { return nil, errors.Errorf("failed to get latest commitID in the repository") } + return &items.Value[0], nil } @@ -205,7 +211,7 @@ func parseUrl(rawUrl string) (*azureOptions, error) { return nil, errors.Errorf("supported url schemes are https and ssh; recevied URL %s rawUrl", rawUrl) } -var expectedSshUrl = "git@ssh.dev.azure.com:v3/Organisation/Project/Repository" +const expectedSshUrl = "git@ssh.dev.azure.com:v3/Organisation/Project/Repository" func parseSshUrl(rawUrl string) (*azureOptions, error) { path := strings.Split(rawUrl, "/") @@ -343,6 +349,7 @@ func (a *azureClient) buildTreeUrl(config *azureOptions, rootObjectHash string) if err != nil { return "", errors.Wrapf(err, "failed to parse list tree url path %s", rawUrl) } + q := u.Query() // projectId={projectId}&recursive=true&fileName={fileName}&$format={$format}&api-version=6.0 q.Set("recursive", "true") @@ -361,9 +368,11 @@ func formatReferenceName(name string) string { if strings.HasPrefix(name, branchPrefix) { return strings.TrimPrefix(name, branchPrefix) } + if strings.HasPrefix(name, tagPrefix) { return strings.TrimPrefix(name, tagPrefix) } + return name } @@ -371,9 +380,11 @@ func getVersionType(name string) string { if strings.HasPrefix(name, branchPrefix) { return "branch" } + if strings.HasPrefix(name, tagPrefix) { return "tag" } + return "commit" } @@ -490,5 +501,6 @@ func checkAzureStatusCode(err error, code int) error { } else if code == http.StatusUnauthorized || code == http.StatusNonAuthoritativeInfo { return gittypes.ErrAuthenticationFailure } + return err } diff --git a/api/git/azure_integration_test.go b/api/git/azure_integration_test.go index 8c22a3e91..f5aba3218 100644 --- a/api/git/azure_integration_test.go +++ b/api/git/azure_integration_test.go @@ -8,14 +8,13 @@ import ( "testing" "time" - _ "github.com/joho/godotenv/autoload" gittypes "github.com/portainer/portainer/api/git/types" + + _ "github.com/joho/godotenv/autoload" "github.com/stretchr/testify/assert" ) -var ( - privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test" -) +const privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test" func TestService_ClonePublicRepository_Azure(t *testing.T) { ensureIntegrationTest(t) @@ -107,7 +106,7 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) { accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") - service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) + service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) go service.ListRefs(privateAzureRepoURL, username, accessToken, false) service.ListRefs(privateAzureRepoURL, username, accessToken, false) @@ -269,7 +268,7 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) { accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT") username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") - service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) + service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) go service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, []string{}) service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, []string{}) diff --git a/api/git/git_integration_test.go b/api/git/git_integration_test.go index 886003c8d..6c72532dc 100644 --- a/api/git/git_integration_test.go +++ b/api/git/git_integration_test.go @@ -60,7 +60,7 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) { accessToken := getRequiredValue(t, "GITHUB_PAT") username := getRequiredValue(t, "GITHUB_USERNAME") - service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) + service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) repositoryUrl := privateGitRepoURL go service.ListRefs(repositoryUrl, username, accessToken, false) @@ -224,7 +224,7 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) { repositoryUrl := privateGitRepoURL accessToken := getRequiredValue(t, "GITHUB_PAT") username := getRequiredValue(t, "GITHUB_USERNAME") - service := newService(context.TODO(), REPOSITORY_CACHE_SIZE, 200*time.Millisecond) + service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) go service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, []string{}) service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, []string{}) diff --git a/api/git/git_test.go b/api/git/git_test.go index 5f129d4f5..0e2d6caeb 100644 --- a/api/git/git_test.go +++ b/api/git/git_test.go @@ -95,10 +95,12 @@ func getCommitHistoryLength(t *testing.T, err error, dir string) int { if err != nil { t.Fatalf("can't open a git repo at %s with error %v", dir, err) } + iter, err := repo.Log(&git.LogOptions{All: true}) if err != nil { t.Fatalf("can't get a commit history iterator with error %v", err) } + count := 0 err = iter.ForEach(func(_ *object.Commit) error { count++ @@ -107,6 +109,7 @@ func getCommitHistoryLength(t *testing.T, err error, dir string) int { if err != nil { t.Fatalf("can't iterate over the commit history with error %v", err) } + return count } diff --git a/api/git/service.go b/api/git/service.go index baf3a841d..7f062cd7a 100644 --- a/api/git/service.go +++ b/api/git/service.go @@ -10,9 +10,9 @@ import ( "github.com/rs/zerolog/log" ) -var ( - REPOSITORY_CACHE_SIZE = 4 - REPOSITORY_CACHE_TTL = 5 * time.Minute +const ( + repositoryCacheSize = 4 + repositoryCacheTTL = 5 * time.Minute ) // baseOption provides a minimum group of information to operate a git repository, like git-remote @@ -58,7 +58,7 @@ type Service struct { // NewService initializes a new service. func NewService(ctx context.Context) *Service { - return newService(ctx, REPOSITORY_CACHE_SIZE, REPOSITORY_CACHE_TTL) + return newService(ctx, repositoryCacheSize, repositoryCacheTTL) } func newService(ctx context.Context, cacheSize int, cacheTTL time.Duration) *Service { diff --git a/api/git/update/update.go b/api/git/update/update.go index d43ef2102..e0c23eefd 100644 --- a/api/git/update/update.go +++ b/api/git/update/update.go @@ -34,7 +34,7 @@ func UpdateGitObject(gitService portainer.GitService, dataStore dataservices.Dat return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId) } - hashChanged := !strings.EqualFold(newHash, string(gitConfig.ConfigHash)) + hashChanged := !strings.EqualFold(newHash, gitConfig.ConfigHash) forceUpdate := autoUpdateConfig != nil && autoUpdateConfig.ForceUpdate if !hashChanged && !forceUpdate { log.Debug(). diff --git a/api/go.sum b/api/go.sum index e8019a424..206c3bdd1 100644 --- a/api/go.sum +++ b/api/go.sum @@ -342,12 +342,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27 h1:PceCpp86SDYb3lZHT4KpuBCkmcJMW5x1qrdFNEfAdUo= -github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209071700-ee11af9c546a h1:4VGM1OH15fqm5rgki0eLF6vND/NxHfoPt3CA6/YdA0k= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209071700-ee11af9c546a/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209082344-8a5b52de366f h1:z/lmLhZMMSIwg70Ap1rPluXNe1vQXH9gfK9K/ols4JA= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209082344-8a5b52de366f/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7 h1:/i985KPNw0KvVtLhTEPUa86aJMtun5ZPOyFCJzdY+dY= github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk= diff --git a/api/hostmanagement/fdo/owner_client.go b/api/hostmanagement/fdo/owner_client.go index bfd10b8ce..0f3aba696 100644 --- a/api/hostmanagement/fdo/owner_client.go +++ b/api/hostmanagement/fdo/owner_client.go @@ -113,7 +113,9 @@ func (c FDOOwnerClient) PutDeviceSVI(info ServiceInfo) error { if err != nil { return err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() if resp.StatusCode != http.StatusOK { return errors.New(http.StatusText(resp.StatusCode)) @@ -132,7 +134,9 @@ func (c FDOOwnerClient) PutDeviceSVIRaw(info url.Values, body []byte) error { if err != nil { return err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() if resp.StatusCode != http.StatusOK { return errors.New(http.StatusText(resp.StatusCode)) @@ -151,7 +155,9 @@ func (c FDOOwnerClient) GetVouchers() ([]string, error) { if err != nil { return nil, err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New(http.StatusText(resp.StatusCode)) @@ -182,7 +188,9 @@ func (c FDOOwnerClient) DeleteVoucher(guid string) error { if err != nil { return err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() if resp.StatusCode != http.StatusOK { return errors.New(http.StatusText(resp.StatusCode)) @@ -201,7 +209,9 @@ func (c FDOOwnerClient) GetDeviceSVI(guid string) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { @@ -225,7 +235,9 @@ func (c FDOOwnerClient) DeleteDeviceSVI(id string) error { if err != nil { return err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() if resp.StatusCode != http.StatusOK { return errors.New(http.StatusText(resp.StatusCode)) diff --git a/api/hostmanagement/openamt/authorization.go b/api/hostmanagement/openamt/authorization.go index 9d5050d2e..463cb4dcf 100644 --- a/api/hostmanagement/openamt/authorization.go +++ b/api/hostmanagement/openamt/authorization.go @@ -33,10 +33,13 @@ func (service *Service) Authorization(configuration portainer.OpenAMTConfigurati if err != nil { return "", err } + defer response.Body.Close() + responseBody, readErr := io.ReadAll(response.Body) if readErr != nil { return "", readErr } + errorResponse := parseError(responseBody) if errorResponse != nil { return "", errorResponse diff --git a/api/hostmanagement/openamt/configCIRA.go b/api/hostmanagement/openamt/configCIRA.go index def1f423c..feac5821a 100644 --- a/api/hostmanagement/openamt/configCIRA.go +++ b/api/hostmanagement/openamt/configCIRA.go @@ -128,6 +128,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig if err != nil { return "", err } + defer response.Body.Close() if response.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status code %s", response.Status) @@ -137,6 +138,8 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig if err != nil { return "", err } + block, _ := pem.Decode(certificate) + return base64.StdEncoding.EncodeToString(block.Bytes), nil } diff --git a/api/hostmanagement/openamt/openamt.go b/api/hostmanagement/openamt/openamt.go index 590664961..c7a738fc4 100644 --- a/api/hostmanagement/openamt/openamt.go +++ b/api/hostmanagement/openamt/openamt.go @@ -103,6 +103,8 @@ func (service *Service) executeSaveRequest(method string, url string, token stri if err != nil { return nil, err } + defer response.Body.Close() + responseBody, readErr := io.ReadAll(response.Body) if readErr != nil { return nil, readErr @@ -132,6 +134,8 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err if err != nil { return nil, err } + defer response.Body.Close() + responseBody, readErr := io.ReadAll(response.Body) if readErr != nil { return nil, readErr @@ -141,10 +145,12 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err if response.StatusCode == http.StatusNotFound { return nil, nil } + errorResponse := parseError(responseBody) if errorResponse != nil { return nil, errorResponse } + return nil, fmt.Errorf("unexpected status code %s", response.Status) } diff --git a/api/http/handler/backup/backup_test.go b/api/http/handler/backup/backup_test.go index a35754db3..23954b9c9 100644 --- a/api/http/handler/backup/backup_test.go +++ b/api/http/handler/backup/backup_test.go @@ -53,6 +53,7 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T) response := w.Result() body, _ := io.ReadAll(response.Body) + response.Body.Close() tmpdir := t.TempDir() @@ -89,6 +90,7 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test response := w.Result() body, _ := io.ReadAll(response.Body) + response.Body.Close() tmpdir := t.TempDir() diff --git a/api/http/handler/backup/restore_test.go b/api/http/handler/backup/restore_test.go index 5c21a67d7..087179fa3 100644 --- a/api/http/handler/backup/restore_test.go +++ b/api/http/handler/backup/restore_test.go @@ -99,6 +99,8 @@ func backup(t *testing.T, h *Handler, password string) []byte { response := w.Result() archive, _ := io.ReadAll(response.Body) + response.Body.Close() + return archive } diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index 68340d7c0..18797f14a 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -6,7 +6,6 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" @@ -26,12 +25,15 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return errors.New("invalid Edge group name") } + if payload.Dynamic && len(payload.TagIDs) == 0 { return errors.New("tagIDs is mandatory for a dynamic Edge group") } + if !payload.Dynamic && len(payload.Endpoints) == 0 { return errors.New("environment is mandatory for a static Edge group") } + return nil } @@ -56,7 +58,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) } var edgeGroup *portainer.EdgeGroup - err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { edgeGroups, err := tx.EdgeGroup().EdgeGroups() if err != nil { @@ -101,13 +102,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) return nil }) - if err != nil { - if httpErr, ok := err.(*httperror.HandlerError); ok { - return httpErr - } - return httperror.InternalServerError("Unexpected error", err) - } - - return response.JSON(w, edgeGroup) + return txResponse(w, edgeGroup, err) } diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index ee1b8b3a5..0cffc1201 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -6,8 +6,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/slices" @@ -27,12 +27,15 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return errors.New("invalid Edge group name") } + if payload.Dynamic && len(payload.TagIDs) == 0 { return errors.New("tagIDs is mandatory for a dynamic Edge group") } + if !payload.Dynamic && len(payload.Endpoints) == 0 { return errors.New("environments is mandatory for a static Edge group") } + return nil } @@ -62,128 +65,135 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid request payload", err) } - edgeGroup, err := handler.DataStore.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err) - } else if err != nil { - return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) - } - - if payload.Name != "" { - edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() - if err != nil { - return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err) - } - for _, edgeGroup := range edgeGroups { - if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) { - return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique")) - } + var edgeGroup *portainer.EdgeGroup + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeGroup, err = tx.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) } - edgeGroup.Name = payload.Name - } - endpoints, err := handler.DataStore.Endpoint().Endpoints() - if err != nil { - return httperror.InternalServerError("Unable to retrieve environments from database", err) - } - - endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() - if err != nil { - return httperror.InternalServerError("Unable to retrieve environment groups from database", err) - } - - oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) - - edgeGroup.Dynamic = payload.Dynamic - if edgeGroup.Dynamic { - edgeGroup.TagIDs = payload.TagIDs - } else { - endpointIDs := []portainer.EndpointID{} - for _, endpointID := range payload.Endpoints { - endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if payload.Name != "" { + edgeGroups, err := tx.EdgeGroup().EdgeGroups() if err != nil { - return httperror.InternalServerError("Unable to retrieve environment from the database", err) + return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err) } - if endpointutils.IsEdgeEndpoint(endpoint) { - endpointIDs = append(endpointIDs, endpoint.ID) + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) { + return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique")) + } } + + edgeGroup.Name = payload.Name } - edgeGroup.Endpoints = endpointIDs - } - if payload.PartialMatch != nil { - edgeGroup.PartialMatch = *payload.PartialMatch - } - - err = handler.DataStore.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup) - if err != nil { - return httperror.InternalServerError("Unable to persist Edge group changes inside the database", err) - } - - newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) - endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...) - - edgeJobs, err := handler.DataStore.EdgeJob().EdgeJobs() - if err != nil { - return httperror.InternalServerError("Unable to fetch Edge jobs", err) - } - - for _, endpointID := range endpointsToUpdate { - err = handler.updateEndpointStacks(endpointID) + endpoints, err := tx.Endpoint().Endpoints() if err != nil { - return httperror.InternalServerError("Unable to persist Environment relation changes inside the database", err) + return httperror.InternalServerError("Unable to retrieve environments from database", err) } - endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + endpointGroups, err := tx.EndpointGroup().EndpointGroups() if err != nil { - return httperror.InternalServerError("Unable to get Environment from database", err) + return httperror.InternalServerError("Unable to retrieve environment groups from database", err) } - if !endpointutils.IsEdgeEndpoint(endpoint) { - continue - } + oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) - var operation string - if slices.Contains(newRelatedEndpoints, endpointID) { - operation = "add" - } else if slices.Contains(oldRelatedEndpoints, endpointID) { - operation = "remove" + edgeGroup.Dynamic = payload.Dynamic + if edgeGroup.Dynamic { + edgeGroup.TagIDs = payload.TagIDs } else { - continue + endpointIDs := []portainer.EndpointID{} + for _, endpointID := range payload.Endpoints { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment from the database", err) + } + + if endpointutils.IsEdgeEndpoint(endpoint) { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + edgeGroup.Endpoints = endpointIDs } - err = handler.updateEndpointEdgeJobs(edgeGroup.ID, endpoint, edgeJobs, operation) + if payload.PartialMatch != nil { + edgeGroup.PartialMatch = *payload.PartialMatch + } + + err = tx.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup) if err != nil { - return httperror.InternalServerError("Unable to persist Environment Edge Jobs changes inside the database", err) + return httperror.InternalServerError("Unable to persist Edge group changes inside the database", err) } - } - return response.JSON(w, edgeGroup) + newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...) + + edgeJobs, err := tx.EdgeJob().EdgeJobs() + if err != nil { + return httperror.InternalServerError("Unable to fetch Edge jobs", err) + } + + for _, endpointID := range endpointsToUpdate { + err = handler.updateEndpointStacks(tx, endpointID) + if err != nil { + return httperror.InternalServerError("Unable to persist Environment relation changes inside the database", err) + } + + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to get Environment from database", err) + } + + if !endpointutils.IsEdgeEndpoint(endpoint) { + continue + } + + var operation string + if slices.Contains(newRelatedEndpoints, endpointID) { + operation = "add" + } else if slices.Contains(oldRelatedEndpoints, endpointID) { + operation = "remove" + } else { + continue + } + + err = handler.updateEndpointEdgeJobs(edgeGroup.ID, endpoint, edgeJobs, operation) + if err != nil { + return httperror.InternalServerError("Unable to persist Environment Edge Jobs changes inside the database", err) + } + } + + return nil + }) + + return txResponse(w, edgeGroup, err) } -func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) error { - relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) +func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) error { + relation, err := tx.EndpointRelation().EndpointRelation(endpointID) if err != nil { return err } - endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + endpoint, err := tx.Endpoint().Endpoint(endpointID) if err != nil { return err } - endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) + endpointGroup, err := tx.EndpointGroup().EndpointGroup(endpoint.GroupID) if err != nil { return err } - edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() + edgeGroups, err := tx.EdgeGroup().EdgeGroups() if err != nil { return err } - edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() + edgeStacks, err := tx.EdgeStack().EdgeStacks() if err != nil { return err } @@ -197,7 +207,7 @@ func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) er relation.EdgeStacks = edgeStackSet - return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) + return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) } func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob, operation string) error { diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go index 5b950a581..3e1a44e74 100644 --- a/api/http/handler/edgegroups/handler.go +++ b/api/http/handler/edgegroups/handler.go @@ -3,11 +3,13 @@ package edgegroups import ( "net/http" - "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/security" + + "github.com/gorilla/mux" ) // Handler is the HTTP handler used to handle environment(endpoint) group operations. @@ -34,3 +36,15 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupDelete)))).Methods(http.MethodDelete) return h } + +func txResponse(w http.ResponseWriter, r any, err error) *httperror.HandlerError { + if err != nil { + if httpErr, ok := err.(*httperror.HandlerError); ok { + return httpErr + } + + return httperror.InternalServerError("Unexpected error", err) + } + + return response.JSON(w, r) +} diff --git a/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go b/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go index c96437604..23d747365 100644 --- a/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go +++ b/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go @@ -81,16 +81,14 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http } if _, ok := handler.DataStore.Endpoint().Heartbeat(portainer.EndpointID(endpointID)); !ok { - return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", nil) + // EE-5910 + return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet")) } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err != nil { - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) - } - - return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + // EE-5910 + return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet")) } err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go index 442f86807..91c32a0f6 100644 --- a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go +++ b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go @@ -30,7 +30,7 @@ var endpointTestCases = []endpointTestCase{ { portainer.Endpoint{}, portainer.EndpointRelation{}, - http.StatusNotFound, + http.StatusForbidden, }, { portainer.Endpoint{ @@ -43,7 +43,7 @@ var endpointTestCases = []endpointTestCase{ portainer.EndpointRelation{ EndpointID: -1, }, - http.StatusNotFound, + http.StatusForbidden, }, { portainer.Endpoint{ diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 94abcf113..ecf16e52e 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -38,7 +38,6 @@ type endpointCreatePayload struct { AzureAuthenticationKey string TagIDs []portainer.TagID EdgeCheckinInterval int - IsEdgeDevice bool } type endpointCreationEnum int @@ -381,7 +380,6 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) EdgeKey: edgeKey, EdgeCheckinInterval: payload.EdgeCheckinInterval, Kubernetes: portainer.KubernetesDefault(), - IsEdgeDevice: payload.IsEdgeDevice, UserTrusted: true, } @@ -435,7 +433,6 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) Status: portainer.EndpointStatusUp, Snapshots: []portainer.DockerSnapshot{}, Kubernetes: portainer.KubernetesDefault(), - IsEdgeDevice: payload.IsEdgeDevice, } err := handler.snapshotAndPersistEndpoint(endpoint) @@ -501,7 +498,6 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, Status: portainer.EndpointStatusUp, Snapshots: []portainer.DockerSnapshot{}, Kubernetes: portainer.KubernetesDefault(), - IsEdgeDevice: payload.IsEdgeDevice, } endpoint.Agent.Version = agentVersion diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go index 1c4e05227..a102c695b 100644 --- a/api/http/handler/endpoints/endpoint_dockerhub_status.go +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "strconv" "strings" @@ -128,7 +129,6 @@ func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Regist } func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhubStatusResponse, error) { - requestURL := "https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest" req, err := http.NewRequest(http.MethodHead, requestURL, nil) @@ -142,7 +142,9 @@ func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhub if err != nil { return nil, err } - defer resp.Body.Close() + + io.Copy(io.Discard, resp.Body) + resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New("failed fetching dockerhub limits") diff --git a/api/http/handler/endpoints/endpoint_settings_update.go b/api/http/handler/endpoints/endpoint_settings_update.go index 68713f385..34a9abf6a 100644 --- a/api/http/handler/endpoints/endpoint_settings_update.go +++ b/api/http/handler/endpoints/endpoint_settings_update.go @@ -28,6 +28,10 @@ type endpointSettingsUpdatePayload struct { AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"` // Whether host management features are enabled EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"` + + EnableGPUManagement *bool `json:"enableGPUManagement" example:"false"` + + Gpus []portainer.Pair `json:"gpus"` } func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error { @@ -107,6 +111,14 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures } + if payload.EnableGPUManagement != nil { + endpoint.EnableGPUManagement = *payload.EnableGPUManagement + } + + if payload.Gpus != nil { + endpoint.Gpus = payload.Gpus + } + endpoint.SecuritySettings = securitySettings err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index e79b7b9e9..50a7dc040 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -82,7 +82,7 @@ type Handler struct { } // @title PortainerCE API -// @version 2.18.0 +// @version 2.19.0 // @description.markdown api-description.md // @termsOfService diff --git a/api/http/handler/hostmanagement/fdo/profile_create.go b/api/http/handler/hostmanagement/fdo/profile_create.go index f4837618d..d83ad9b50 100644 --- a/api/http/handler/hostmanagement/fdo/profile_create.go +++ b/api/http/handler/hostmanagement/fdo/profile_create.go @@ -48,15 +48,16 @@ func (handler *Handler) createProfile(w http.ResponseWriter, r *http.Request) *h return httperror.BadRequest("Invalid query parameter: method", err) } - switch method { - case "editor": + if method == "editor" { return handler.createFDOProfileFromFileContent(w, r) } + return httperror.BadRequest("Invalid method. Value must be one of: editor", errors.New("invalid method")) } func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { var payload createProfileFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return httperror.BadRequest("Invalid request payload", err) @@ -66,6 +67,7 @@ func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r if err != nil { return httperror.InternalServerError(err.Error(), err) } + if !isUnique { return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A profile with the name '%s' already exists", payload.Name), Err: errors.New("a profile already exists with this name")} } @@ -80,6 +82,7 @@ func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r if err != nil { return httperror.InternalServerError("Unable to persist profile file on disk", err) } + profile.FilePath = filePath profile.DateCreated = time.Now().Unix() diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index 3eb9d3a9b..13e0bcb3f 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -1,7 +1,6 @@ package stacks import ( - "fmt" "net/http" "time" @@ -10,7 +9,6 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" @@ -137,12 +135,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) } } - backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath) - err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath) - if err != nil { - return httperror.InternalServerError("Unable to move git repository directory", err) - } - repositoryUsername := "" repositoryPassword := "" if payload.RepositoryAuthentication { diff --git a/api/http/handler/system/system_info.go b/api/http/handler/system/system_info.go index bd2e413a4..d39949cd4 100644 --- a/api/http/handler/system/system_info.go +++ b/api/http/handler/system/system_info.go @@ -10,10 +10,9 @@ import ( ) type systemInfoResponse struct { - Platform plf.ContainerPlatform `json:"platform"` - EdgeAgents int `json:"edgeAgents"` - EdgeDevices int `json:"edgeDevices"` - Agents int `json:"agents"` + Platform plf.ContainerPlatform `json:"platform"` + EdgeAgents int `json:"edgeAgents"` + Agents int `json:"agents"` } // @id systemInfo @@ -34,7 +33,6 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http agents := 0 edgeAgents := 0 - edgeDevices := 0 for _, environment := range environments { if endpointutils.IsAgentEndpoint(&environment) { @@ -45,9 +43,6 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http edgeAgents++ } - if environment.IsEdgeDevice { - edgeDevices++ - } } platform, err := plf.DetermineContainerPlatform() @@ -56,9 +51,8 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http } return response.JSON(w, &systemInfoResponse{ - EdgeAgents: edgeAgents, - EdgeDevices: edgeDevices, - Agents: agents, - Platform: platform, + EdgeAgents: edgeAgents, + Agents: agents, + Platform: platform, }) } diff --git a/api/http/handler/system/system_upgrade.go b/api/http/handler/system/system_upgrade.go index 66bb27852..0669a742d 100644 --- a/api/http/handler/system/system_upgrade.go +++ b/api/http/handler/system/system_upgrade.go @@ -8,6 +8,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/platform" ) type systemUpgradePayload struct { @@ -28,13 +30,19 @@ 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 // @description **Access policy**: administrator // @tags system // @produce json -// @success 200 {object} status "Success" +// @success 204 {object} status "Success" // @router /system/upgrade [post] func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { payload, err := request.GetPayload[systemUpgradePayload](r) @@ -42,10 +50,40 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h return httperror.BadRequest("Invalid request payload", err) } - err = handler.upgradeService.Upgrade(payload.License) + environment, err := handler.guessLocalEndpoint() + if err != nil { + return httperror.InternalServerError("Failed to guess local endpoint", err) + } + + err = handler.upgradeService.Upgrade(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/templates/template_file.go b/api/http/handler/templates/template_file.go index 3299f25cd..e7e5a23a0 100644 --- a/api/http/handler/templates/template_file.go +++ b/api/http/handler/templates/template_file.go @@ -63,6 +63,7 @@ func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperr return nil } } + return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist")) } @@ -82,6 +83,7 @@ func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperr // @router /templates/file [post] func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { var payload filePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return httperror.BadRequest("Invalid request payload", err) @@ -112,11 +114,9 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht } -func (handler *Handler) cleanUp(projectPath string) error { +func (handler *Handler) cleanUp(projectPath string) { err := handler.FileService.RemoveDirectory(projectPath) if err != nil { log.Debug().Err(err).Msg("HTTP error: unable to cleanup stack creation") } - - return nil } diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index 43d8b4dab..844f38806 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -18,8 +18,6 @@ import ( type themePayload struct { // Color represents the color theme of the UI Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` - // SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way - SubtleUpgradeButton *bool `json:"subtleUpgradeButton" example:"false"` } type userUpdatePayload struct { @@ -33,11 +31,11 @@ type userUpdatePayload struct { func (payload *userUpdatePayload) Validate(r *http.Request) error { if govalidator.Contains(payload.Username, " ") { - return errors.New("Invalid username. Must not contain any whitespace") + return errors.New("invalid username. Must not contain any whitespace") } if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { - return errors.New("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") } return nil } @@ -120,10 +118,6 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http if payload.Theme.Color != nil { user.ThemeSettings.Color = *payload.Theme.Color } - - if payload.Theme.SubtleUpgradeButton != nil { - user.ThemeSettings.SubtleUpgradeButton = *payload.Theme.SubtleUpgradeButton - } } if payload.Role != 0 { diff --git a/api/http/handler/websocket/stream.go b/api/http/handler/websocket/stream.go index 131951803..77ce78566 100644 --- a/api/http/handler/websocket/stream.go +++ b/api/http/handler/websocket/stream.go @@ -14,48 +14,57 @@ func streamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer _, in, err := websocketConn.ReadMessage() if err != nil { errorChan <- err + break } _, err = writer.Write(in) if err != nil { errorChan <- err + break } } } func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) { + out := make([]byte, readerBufferSize) + for { - out := make([]byte, readerBufferSize) - _, err := reader.Read(out) + n, err := reader.Read(out) if err != nil { errorChan <- err + break } - processedOutput := validString(string(out[:])) + processedOutput := validString(string(out[:n])) err = websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput)) if err != nil { errorChan <- err + break } } } func validString(s string) string { - if !utf8.ValidString(s) { - v := make([]rune, 0, len(s)) - for i, r := range s { - if r == utf8.RuneError { - _, size := utf8.DecodeRuneInString(s[i:]) - if size == 1 { - continue - } - } - v = append(v, r) - } - s = string(v) + if utf8.ValidString(s) { + return s } - return s + + v := make([]rune, 0, len(s)) + + for i, r := range s { + if r == utf8.RuneError { + _, size := utf8.DecodeRuneInString(s[i:]) + if size == 1 { + continue + } + } + + v = append(v, r) + } + + return string(v) } diff --git a/api/http/proxy/factory/docker/portainer.go b/api/http/proxy/factory/docker/portainer.go index 437d0427f..be046db0d 100644 --- a/api/http/proxy/factory/docker/portainer.go +++ b/api/http/proxy/factory/docker/portainer.go @@ -26,6 +26,7 @@ func (transport *Transport) applyPortainerContainers(resources []interface{}) ([ responseObject, _ = transport.applyPortainerContainer(responseObject) decoratedResourceData = append(decoratedResourceData, responseObject) } + return decoratedResourceData, nil } @@ -34,8 +35,10 @@ func (transport *Transport) applyPortainerContainer(resourceObject map[string]in if !ok { return resourceObject, nil } + if len(resourceId) >= 12 && resourceId[0:12] == portainerContainerId { resourceObject["IsPortainer"] = true } + return resourceObject, nil } diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 1f05092f8..aeab4d876 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -150,8 +150,7 @@ func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenMa func decorateAgentRequest(r *http.Request, dataStore dataservices.DataStore) error { requestPath := strings.TrimPrefix(r.URL.Path, "/v2") - switch { - case strings.HasPrefix(requestPath, "/dockerhub"): + if strings.HasPrefix(requestPath, "/dockerhub") { return decorateAgentDockerHubRequest(r, dataStore) } diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 163f785c7..918217ff3 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -76,6 +76,16 @@ func EndpointSet(endpointIDs []portainer.EndpointID) map[portainer.EndpointID]bo } func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerIngressClassDetected { + return + } + defer func() { + endpoint.Kubernetes.Flags.IsServerIngressClassDetected = true + endpointService.UpdateEndpoint( + portainer.EndpointID(endpoint.ID), + endpoint, + ) + }() cli, err := factory.GetKubeClient(endpoint) if err != nil { log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection") @@ -107,6 +117,16 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService } func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerMetricsDetected { + return + } + defer func() { + endpoint.Kubernetes.Flags.IsServerMetricsDetected = true + endpointService.UpdateEndpoint( + portainer.EndpointID(endpoint.ID), + endpoint, + ) + }() cli, err := factory.GetKubeClient(endpoint) if err != nil { log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection") @@ -118,11 +138,6 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas return } endpoint.Kubernetes.Configuration.UseServerMetrics = true - endpoint.Kubernetes.Flags.IsServerMetricsDetected = true - err = endpointService.UpdateEndpoint( - portainer.EndpointID(endpoint.ID), - endpoint, - ) if err != nil { log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database") return @@ -158,6 +173,16 @@ func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.En } func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerStorageDetected { + return + } + defer func() { + endpoint.Kubernetes.Flags.IsServerStorageDetected = true + endpointService.UpdateEndpoint( + portainer.EndpointID(endpoint.ID), + endpoint, + ) + }() log.Info().Msg("attempting to detect storage classes in the cluster") err := storageDetect(endpoint, endpointService, factory) if err == nil { diff --git a/api/internal/upgrade/upgrade.go b/api/internal/upgrade/upgrade.go index 73283972d..a4552fae3 100644 --- a/api/internal/upgrade/upgrade.go +++ b/api/internal/upgrade/upgrade.go @@ -1,23 +1,13 @@ package upgrade import ( - "bytes" - "context" "fmt" - "os" - "strings" - "time" - "github.com/cbroglie/mustache" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/filters" "github.com/pkg/errors" libstack "github.com/portainer/docker-compose-wrapper" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/docker" - "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/platform" - "github.com/rs/zerolog/log" ) const ( @@ -36,19 +26,23 @@ const ( ) type Service interface { - Upgrade(licenseKey string) error + Upgrade(environment *portainer.Endpoint, licenseKey string) error } type service struct { - composeDeployer libstack.Deployer - isUpdating bool - platform platform.ContainerPlatform - assetsPath string + composeDeployer libstack.Deployer + kubernetesClientFactory *cli.ClientFactory + + isUpdating bool + platform platform.ContainerPlatform + + assetsPath string } func NewService( assetsPath string, composeDeployer libstack.Deployer, + kubernetesClientFactory *cli.ClientFactory, ) (Service, error) { platform, err := platform.DetermineContainerPlatform() if err != nil { @@ -56,13 +50,14 @@ func NewService( } return &service{ - assetsPath: assetsPath, - composeDeployer: composeDeployer, - platform: platform, + assetsPath: assetsPath, + composeDeployer: composeDeployer, + kubernetesClientFactory: kubernetesClientFactory, + platform: platform, }, nil } -func (service *service) Upgrade(licenseKey string) error { +func (service *service) Upgrade(environment *portainer.Endpoint, licenseKey string) error { service.isUpdating = true switch service.platform { @@ -70,113 +65,9 @@ func (service *service) Upgrade(licenseKey string) error { return service.upgradeDocker(licenseKey, portainer.APIVersion, "standalone") case platform.PlatformDockerSwarm: return service.upgradeDocker(licenseKey, portainer.APIVersion, "swarm") - // case platform.PlatformKubernetes: - // case platform.PlatformPodman: - // case platform.PlatformNomad: - // default: + case platform.PlatformKubernetes: + return service.upgradeKubernetes(environment, licenseKey, portainer.APIVersion) } return fmt.Errorf("unsupported platform %s", service.platform) } - -func (service *service) upgradeDocker(licenseKey, version, envType string) error { - ctx := context.TODO() - templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile) - - portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) - if portainerImagePrefix == "" { - portainerImagePrefix = "portainer/portainer-ee" - } - - image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) - - skipPullImage := os.Getenv(skipPullImageEnvVar) - - if err := service.checkImage(ctx, image, skipPullImage != ""); err != nil { - return err - } - - composeFile, err := mustache.RenderFile(templateName, map[string]string{ - "image": image, - "skip_pull_image": skipPullImage, - "updater_image": os.Getenv(updaterImageEnvVar), - "license": licenseKey, - "envType": envType, - }) - - log.Debug(). - Str("composeFile", composeFile). - Msg("Compose file for upgrade") - - if err != nil { - 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)) - - r := bytes.NewReader([]byte(composeFile)) - - err = filesystem.CreateFile(filePath, r) - if err != nil { - return errors.Wrap(err, "failed to create upgrade compose file") - } - - projectName := fmt.Sprintf( - "portainer-upgrade-%d-%s", - timeId, - strings.Replace(version, ".", "-", -1)) - - err = service.composeDeployer.Deploy( - ctx, - []string{filePath}, - libstack.DeployOptions{ - ForceRecreate: true, - AbortOnContainerExit: true, - Options: libstack.Options{ - ProjectName: projectName, - }, - }, - ) - - // optimally, server was restarted by the updater, so we should not reach this point - - 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") -} - -func (service *service) checkImage(ctx context.Context, image string, skipPullImage bool) error { - cli, err := docker.CreateClientFromEnv() - 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: filters, - }) - if err != nil { - return errors.Wrap(err, "failed to list images") - } - - if len(images) == 0 { - return errors.Errorf("image %s not found locally", image) - } - - return nil - } else { - // check if available on registry - _, err := cli.DistributionInspect(ctx, image, "") - if err != nil { - return errors.Errorf("image %s not found on registry", image) - } - - return nil - } -} diff --git a/api/internal/upgrade/upgrade_docker.go b/api/internal/upgrade/upgrade_docker.go new file mode 100644 index 000000000..c4865433f --- /dev/null +++ b/api/internal/upgrade/upgrade_docker.go @@ -0,0 +1,122 @@ +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" + libstack "github.com/portainer/docker-compose-wrapper" + "github.com/portainer/portainer/api/filesystem" + + "github.com/cbroglie/mustache" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" +) + +func (service *service) upgradeDocker(licenseKey, version, envType string) error { + ctx := context.TODO() + templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile) + + portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) + if portainerImagePrefix == "" { + portainerImagePrefix = "portainer/portainer-ee" + } + + image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) + + skipPullImage := os.Getenv(skipPullImageEnvVar) + + if err := service.checkImageForDocker(ctx, image, skipPullImage != ""); err != nil { + return err + } + + composeFile, err := mustache.RenderFile(templateName, map[string]string{ + "image": image, + "skip_pull_image": skipPullImage, + "updater_image": os.Getenv(updaterImageEnvVar), + "license": licenseKey, + "envType": envType, + }) + + log.Debug(). + Str("composeFile", composeFile). + Msg("Compose file for upgrade") + + if err != nil { + 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)) + + r := bytes.NewReader([]byte(composeFile)) + + err = filesystem.CreateFile(filePath, r) + if err != nil { + return errors.Wrap(err, "failed to create upgrade compose file") + } + + 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, + }, + }, + ) + + // optimally, server was restarted by the updater, so we should not reach this point + + 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") +} + +func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error { + cli, err := client.NewClientWithOpts(client.FromEnv) + 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: filters, + }) + if err != nil { + return errors.Wrap(err, "failed to list images") + } + + if len(images) == 0 { + return errors.Errorf("image %s not found locally", image) + } + + return nil + } else { + // check if available on registry + _, err := cli.DistributionInspect(ctx, image, "") + if err != nil { + return errors.Errorf("image %s not found on registry", image) + } + + return nil + } +} diff --git a/api/internal/upgrade/upgrade_kubernetes.go b/api/internal/upgrade/upgrade_kubernetes.go new file mode 100644 index 000000000..83bca9b78 --- /dev/null +++ b/api/internal/upgrade/upgrade_kubernetes.go @@ -0,0 +1,201 @@ +package upgrade + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +func ptr[T any](i T) *T { return &i } + +func (service *service) upgradeKubernetes(environment *portainer.Endpoint, licenseKey, version string) error { + ctx := context.TODO() + + kubeCLI, err := service.kubernetesClientFactory.CreateClient(environment) + if err != nil { + return errors.WithMessage(err, "failed to get kubernetes client") + } + + namespace := "portainer" + taskName := fmt.Sprintf("portainer-upgrade-%d", time.Now().Unix()) + + jobsCli := kubeCLI.BatchV1().Jobs(namespace) + + updaterImage := os.Getenv(updaterImageEnvVar) + if updaterImage == "" { + updaterImage = "portainer/portainer-updater:latest" + } + + portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) + if portainerImagePrefix == "" { + portainerImagePrefix = "portainer/portainer-ee" + } + + image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) + + if err := service.checkImageForKubernetes(ctx, kubeCLI, namespace, image); err != nil { + return err + } + + job, err := jobsCli.Create(ctx, &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: taskName, + Namespace: namespace, + }, + + Spec: batchv1.JobSpec{ + TTLSecondsAfterFinished: ptr[int32](5 * 60), // cleanup after 5 minutes + BackoffLimit: ptr[int32](0), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + + RestartPolicy: "Never", + ServiceAccountName: "portainer-sa-clusteradmin", + Containers: []corev1.Container{ + { + Name: taskName, + Image: updaterImage, + Args: []string{ + "--pretty-log", + "--log-level", "DEBUG", + "portainer", + "--env-type", "kubernetes", + "--image", image, + "--license", licenseKey, + }, + }, + }, + }, + }, + }, + }, metav1.CreateOptions{}) + + if err != nil { + return errors.WithMessage(err, "failed to create upgrade job") + } + + watcher, err := jobsCli.Watch(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=" + taskName, + TimeoutSeconds: ptr[int64](60), + }) + if err != nil { + return errors.WithMessage(err, "failed to watch upgrade job") + } + + for event := range watcher.ResultChan() { + job, ok := event.Object.(*batchv1.Job) + if !ok { + continue + } + + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobComplete { + log.Debug(). + Str("job", job.Name). + Msg("Upgrade job completed") + return nil + } + + if c.Type == batchv1.JobFailed { + return fmt.Errorf("upgrade failed: %s", c.Message) + } + } + } + + log.Debug(). + Str("job", job.Name). + Msg("Upgrade job created") + + return errors.New("upgrade failed: server should have been restarted by the updater") + +} + +func (service *service) checkImageForKubernetes(ctx context.Context, kubeCLI *kubernetes.Clientset, namespace, image string) error { + podsCli := kubeCLI.CoreV1().Pods(namespace) + + log.Debug(). + Str("image", image). + Msg("Checking image") + + podName := fmt.Sprintf("portainer-image-check-%d", time.Now().Unix()) + _, err := podsCli.Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + }, + Spec: corev1.PodSpec{ + RestartPolicy: "Never", + + Containers: []corev1.Container{ + { + Name: fmt.Sprint(podName, "-container"), + Image: image, + }, + }, + }, + }, metav1.CreateOptions{}) + + if err != nil { + log.Warn().Err(err).Msg("failed to create image check pod") + return errors.WithMessage(err, "failed to create image check pod") + } + + defer func() { + log.Debug(). + Str("pod", podName). + Msg("Deleting image check pod") + + if err := podsCli.Delete(ctx, podName, metav1.DeleteOptions{}); err != nil { + log.Warn().Err(err).Msg("failed to delete image check pod") + } + }() + + i := 0 + for { + time.Sleep(2 * time.Second) + + log.Debug(). + Str("image", image). + Int("try", i). + Msg("Checking image") + + i++ + + pod, err := podsCli.Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return errors.WithMessage(err, "failed to get image check pod") + } + + for _, containerStatus := range pod.Status.ContainerStatuses { + if containerStatus.Ready { + log.Debug(). + Str("image", image). + Str("pod", podName). + Msg("Image check container ready, assuming image is available") + + return nil + } + + if containerStatus.State.Waiting != nil { + if containerStatus.State.Waiting.Reason == "ErrImagePull" || containerStatus.State.Waiting.Reason == "ImagePullBackOff" { + log.Debug(). + Str("image", image). + Str("pod", podName). + Str("reason", containerStatus.State.Waiting.Reason). + Str("message", containerStatus.State.Waiting.Message). + Str("container", containerStatus.Name). + Msg("Image check container failed because of missing image") + return fmt.Errorf("image %s not found", image) + } + } + } + } +} diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index 729e3c801..20a8e070e 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -12,8 +12,8 @@ import ( // NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error { - kcl.lock.Lock() - defer kcl.lock.Unlock() + kcl.mu.Lock() + defer kcl.mu.Unlock() policies, err := kcl.GetNamespaceAccessPolicies() if err != nil { @@ -42,6 +42,7 @@ func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNam if err != nil { return nil, err } + return policies, nil } diff --git a/api/kubernetes/cli/access_test.go b/api/kubernetes/cli/access_test.go index db250546c..09bb28d9e 100644 --- a/api/kubernetes/cli/access_test.go +++ b/api/kubernetes/cli/access_test.go @@ -2,7 +2,6 @@ package cli import ( "context" - "sync" "testing" portainer "github.com/portainer/portainer/api" @@ -40,7 +39,6 @@ func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConf k := &KubeClient{ cli: kfake.NewSimpleClientset(), instanceID: "instance", - lock: &sync.Mutex{}, } config := &ktypes.ConfigMap{ diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index d15d486bb..0b2157c73 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -7,17 +7,20 @@ import ( "sync" "time" - cmap "github.com/orcaman/concurrent-map" "github.com/patrickmn/go-cache" "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - "github.com/rs/zerolog/log" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) +const ( + DefaultKubeClientQPS = 30 + DefaultKubeClientBurst = 100 +) + type ( // ClientFactory is used to create Kubernetes clients ClientFactory struct { @@ -25,16 +28,17 @@ type ( reverseTunnelService portainer.ReverseTunnelService signatureService portainer.DigitalSignatureService instanceID string - endpointClients cmap.ConcurrentMap + endpointClients map[string]*KubeClient endpointProxyClients *cache.Cache AddrHTTPS string + mu sync.Mutex } // KubeClient represent a service used to execute Kubernetes operations KubeClient struct { cli kubernetes.Interface instanceID string - lock *sync.Mutex + mu sync.Mutex } ) @@ -53,7 +57,7 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers signatureService: signatureService, reverseTunnelService: reverseTunnelService, instanceID: instanceID, - endpointClients: cmap.New(), + endpointClients: make(map[string]*KubeClient), endpointProxyClients: cache.New(timeout, timeout), AddrHTTPS: addrHTTPS, }, nil @@ -65,82 +69,87 @@ func (factory *ClientFactory) GetInstanceID() (instanceID string) { // Remove the cached kube client so a new one can be created func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { - factory.endpointClients.Remove(strconv.Itoa(int(endpointID))) + factory.mu.Lock() + delete(factory.endpointClients, strconv.Itoa(int(endpointID))) + factory.mu.Unlock() } // GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. // If no client is registered, it will create a new client, register it, and returns it. -func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { +func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { + factory.mu.Lock() + defer factory.mu.Unlock() + key := strconv.Itoa(int(endpoint.ID)) - client, ok := factory.endpointClients.Get(key) + client, ok := factory.endpointClients[key] if !ok { - client, err := factory.createCachedAdminKubeClient(endpoint) + var err error + + client, err = factory.createCachedAdminKubeClient(endpoint) if err != nil { return nil, err } - factory.endpointClients.Set(key, client) - return client, nil + factory.endpointClients[key] = client } - return client.(portainer.KubeClient), nil + return client, nil } // GetProxyKubeClient retrieves a KubeClient from the cache. You should be // calling SetProxyKubeClient before first. It is normally, called the // kubernetes middleware. -func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (portainer.KubeClient, bool) { +func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (*KubeClient, bool) { client, ok := factory.endpointProxyClients.Get(endpointID + "." + token) if !ok { return nil, false } - return client.(portainer.KubeClient), true + + return client.(*KubeClient), true } // SetProxyKubeClient stores a kubeclient in the cache. -func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli portainer.KubeClient) { +func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli *KubeClient) { factory.endpointProxyClients.Set(endpointID+"."+token, cli, 0) } // CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and // Kubernetes config. -func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (portainer.KubeClient, error) { +func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (*KubeClient, error) { config, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig)) if err != nil { return nil, err } + cliConfig, err := config.ClientConfig() if err != nil { return nil, err } + cliConfig.QPS = DefaultKubeClientQPS + cliConfig.Burst = DefaultKubeClientBurst + cli, err := kubernetes.NewForConfig(cliConfig) if err != nil { return nil, err } - kubecli := &KubeClient{ + return &KubeClient{ cli: cli, instanceID: factory.instanceID, - lock: &sync.Mutex{}, - } - - return kubecli, nil + }, nil } -func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { +func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { cli, err := factory.CreateClient(endpoint) if err != nil { return nil, err } - kubecli := &KubeClient{ + return &KubeClient{ cli: cli, instanceID: factory.instanceID, - lock: &sync.Mutex{}, - } - - return kubecli, nil + }, nil } // CreateClient returns a pointer to a new Clientset instance @@ -199,7 +208,10 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete if err != nil { return nil, err } + config.Insecure = true + config.QPS = DefaultKubeClientQPS + config.Burst = DefaultKubeClientBurst config.Wrap(func(rt http.RoundTripper) http.RoundTripper { return &agentHeaderRoundTripper{ @@ -218,30 +230,13 @@ func buildLocalClient() (*kubernetes.Clientset, error) { return nil, err } + config.QPS = DefaultKubeClientQPS + config.Burst = DefaultKubeClientBurst + return kubernetes.NewForConfig(config) } -func (factory *ClientFactory) PostInitMigrateIngresses() error { - endpoints, err := factory.dataStore.Endpoint().Endpoints() - if err != nil { - return err - } - for i := range endpoints { - // Early exit if we do not need to migrate! - if endpoints[i].PostInitMigrations.MigrateIngresses == false { - return nil - } - - err := factory.migrateEndpointIngresses(&endpoints[i]) - if err != nil { - log.Debug().Err(err).Msg("failure migrating endpoint ingresses") - } - } - - return nil -} - -func (factory *ClientFactory) migrateEndpointIngresses(e *portainer.Endpoint) error { +func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error { // classes is a list of controllers which have been manually added to the // cluster setup view. These need to all be allowed globally, but then // blocked in specific namespaces which they were not previously allowed in. diff --git a/api/kubernetes/cli/namespace_test.go b/api/kubernetes/cli/namespace_test.go index 9d3647877..ea4821bab 100644 --- a/api/kubernetes/cli/namespace_test.go +++ b/api/kubernetes/cli/namespace_test.go @@ -3,7 +3,6 @@ package cli import ( "context" "strconv" - "sync" "testing" portainer "github.com/portainer/portainer/api" @@ -19,7 +18,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) @@ -37,12 +35,10 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) assert.Error(t, err) - }) t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) { @@ -61,7 +57,6 @@ func Test_ToggleSystemState(t *testing.T) { systemNamespaceLabel: strconv.FormatBool(test.isSystem), }}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, test.isSystem) @@ -81,7 +76,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) @@ -102,7 +96,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, false) @@ -125,7 +118,6 @@ func Test_ToggleSystemState(t *testing.T) { systemNamespaceLabel: "true", }}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, false) @@ -159,7 +151,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(namespace, config), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) @@ -178,6 +169,5 @@ func Test_ToggleSystemState(t *testing.T) { actualPolicies, err := kcl.GetNamespaceAccessPolicies() assert.NoError(t, err, "failed to fetch policies") assert.Equal(t, expectedPolicies, actualPolicies) - }) } diff --git a/api/kubernetes/kubeclusteraccess_service.go b/api/kubernetes/kubeclusteraccess_service.go index 4a97885d4..a5145d585 100644 --- a/api/kubernetes/kubeclusteraccess_service.go +++ b/api/kubernetes/kubeclusteraccess_service.go @@ -98,7 +98,7 @@ func (service *kubeClusterAccessService) GetData(hostURL string, endpointID port // When the api call is internal, the baseURL should not be used. if hostURL == "localhost" { - hostURL = hostURL + service.httpsBindAddr + hostURL += service.httpsBindAddr baseURL = "/" } diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index 9843258b2..3f15f8f92 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -121,12 +121,13 @@ func getResource(token string, configuration *portainer.OAuthSettings) (map[stri client := &http.Client{} req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err diff --git a/api/platform/platform.go b/api/platform/platform.go index 8adee3cb3..706a844ef 100644 --- a/api/platform/platform.go +++ b/api/platform/platform.go @@ -41,10 +41,12 @@ func DetermineContainerPlatform() (ContainerPlatform, error) { if podmanModeEnvVar == "1" { return PlatformPodman, nil } + serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost) if serviceHostKubernetesEnvVar != "" { return PlatformKubernetes, nil } + nomadJobName := os.Getenv(NomadJobName) if nomadJobName != "" { return PlatformNomad, nil diff --git a/api/portainer.go b/api/portainer.go index 918c32aaa..f816deef1 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -225,7 +225,7 @@ type ( // It contains some information of Docker's ContainerJSON struct DockerContainerSnapshot struct { types.Container - Env []string `json:"Env"` + Env []string `json:"Env,omitempty"` // EE-5240 } // DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API @@ -388,8 +388,7 @@ type ( LastCheckInDate int64 // QueryDate of each query with the endpoints list QueryDate int64 - // IsEdgeDevice marks if the environment was created as an EdgeDevice - IsEdgeDevice bool + // Whether the device has been trusted or not by the user UserTrusted bool @@ -402,6 +401,8 @@ type ( Version string `example:"1.0.0"` } + EnableGPUManagement bool `json:"EnableGPUManagement"` + // Deprecated fields // Deprecated in DBVersion == 4 TLS bool `json:"TLS,omitempty"` @@ -415,6 +416,9 @@ type ( // Deprecated in DBVersion == 22 Tags []string `json:"Tags"` + + // Deprecated v2.18 + IsEdgeDevice bool } EnvironmentEdgeSettings struct { @@ -502,6 +506,7 @@ type ( // EndpointPostInitMigrations EndpointPostInitMigrations struct { MigrateIngresses bool `json:"MigrateIngresses"` + MigrateGPUs bool `json:"MigrateGPUs"` } // Extension represents a deprecated Portainer extension @@ -585,9 +590,12 @@ type ( Flags KubernetesFlags `json:"Flags"` } + // KubernetesFlags are used to detect if we need to run initial cluster + // detection again. KubernetesFlags struct { - IsServerMetricsDetected bool `json:"IsServerMetricsDetected"` - IsServerStorageDetected bool `json:"IsServerStorageDetected"` + IsServerMetricsDetected bool `json:"IsServerMetricsDetected"` + IsServerIngressClassDetected bool `json:"IsServerIngressClassDetected"` + IsServerStorageDetected bool `json:"IsServerStorageDetected"` } // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time @@ -1283,8 +1291,6 @@ type ( UserThemeSettings struct { // Color represents the color theme of the UI Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` - // SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way - SubtleUpgradeButton bool `json:"subtleUpgradeButton"` } // Webhook represents a url webhook that can be used to update a service @@ -1507,7 +1513,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.18.0" + APIVersion = "2.19.0" // Edition is what this edition of Portainer is called Edition = PortainerCE // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax @@ -1554,7 +1560,9 @@ const ( ) // List of supported features -var SupportedFeatureFlags = []featureflags.Feature{} +var SupportedFeatureFlags = []featureflags.Feature{ + "fdo", +} const ( _ AuthenticationMethod = iota diff --git a/api/stacks/stackutils/util.go b/api/stacks/stackutils/util.go index bb3d82d23..b1c8f17ba 100644 --- a/api/stacks/stackutils/util.go +++ b/api/stacks/stackutils/util.go @@ -24,6 +24,7 @@ func GetStackFilePaths(stack *portainer.Stack, absolute bool) []string { for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file)) } + return filePaths } diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index a052f6b29..d4a121a7b 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -120,8 +120,6 @@ --bg-navtabs-hover-color: var(--grey-16); --bg-nav-tab-active-color: var(--ui-gray-4); --bg-table-selected-color: var(--grey-14); - --bg-codemirror-color: var(--white-color); - --bg-codemirror-gutters-color: var(--grey-17); --bg-dropdown-menu-color: var(--white-color); --bg-log-viewer-color: var(--white-color); --bg-log-line-selected-color: var(--grey-18); @@ -136,7 +134,6 @@ --bg-item-highlighted-color: var(--grey-21); --bg-item-highlighted-null-color: var(--grey-14); --bg-panel-body-color: var(--white-color); - --bg-codemirror-selected-color: var(--grey-22); --bg-tooltip-color: var(--ui-gray-11); --bg-input-sm-color: var(--white-color); --bg-app-datatable-thead: var(--grey-23); @@ -182,11 +179,7 @@ --text-navtabs-color: var(--grey-7); --text-navtabs-hover-color: var(--grey-6); --text-nav-tab-active-color: var(--grey-25); - --text-cm-default-color: var(--blue-1); - --text-cm-meta-color: var(--black-color); - --text-cm-string-color: var(--red-3); - --text-cm-number-color: var(--green-1); - --text-codemirror-color: var(--black-color); + --text-dropdown-menu-color: var(--grey-6); --text-log-viewer-color: var(--black-color); --text-json-tree-color: var(--blue-3); @@ -224,7 +217,6 @@ --border-md-checkbox-color: var(--grey-19); --border-modal-header-color: var(--grey-45); --border-navtabs-color: var(--ui-white); - --border-codemirror-cursor-color: var(--black-color); --border-pre-color: var(--grey-43); --border-pagination-span-color: var(--ui-white); --border-pagination-hover-color: var(--ui-white); @@ -281,9 +273,6 @@ --bg-card-color: var(--grey-1); --bg-checkbox-border-color: var(--grey-8); --bg-code-color: var(--grey-2); - --bg-codemirror-color: var(--grey-2); - --bg-codemirror-gutters-color: var(--grey-3); - --bg-codemirror-selected-color: var(--grey-3); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-main-color: var(--grey-2); --bg-sidebar-color: var(--grey-1); @@ -361,11 +350,7 @@ --text-navtabs-color: var(--grey-8); --text-navtabs-hover-color: var(--grey-9); --text-nav-tab-active-color: var(--white-color); - --text-cm-default-color: var(--blue-10); - --text-cm-meta-color: var(--white-color); - --text-cm-string-color: var(--red-5); - --text-cm-number-color: var(--green-2); - --text-codemirror-color: var(--white-color); + --text-dropdown-menu-color: var(--white-color); --text-log-viewer-color: var(--white-color); --text-json-tree-color: var(--grey-40); @@ -403,7 +388,6 @@ --border-md-checkbox-color: var(--grey-41); --border-modal-header-color: var(--grey-1); --border-navtabs-color: var(--grey-38); - --border-codemirror-cursor-color: var(--white-color); --border-pre-color: var(--grey-3); --border-blocklist: var(--ui-gray-9); --border-blocklist-item-selected-color: var(--grey-38); @@ -468,15 +452,12 @@ --bg-switch-box-color: var(--grey-53); --bg-panel-body-color: var(--black-color); --bg-dropdown-menu-color: var(--ui-gray-warm-8); - --bg-codemirror-selected-color: var(--grey-3); --bg-motd-body-color: var(--black-color); --bg-blocklist-hover-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color); --bg-input-group-addon-color: var(--grey-3); --bg-table-color: var(--black-color); - --bg-codemirror-gutters-color: var(--ui-gray-warm-11); - --bg-codemirror-color: var(--black-color); - --bg-codemirror-selected-color: var(--grey-3); + --bg-log-viewer-color: var(--black-color); --bg-log-line-selected-color: var(--grey-3); --bg-modal-content-color: var(--black-color); @@ -536,7 +517,6 @@ --text-tooltip-color: var(--white-color); --text-blocklist-item-selected-color: var(--blue-9); --text-input-group-addon-color: var(--white-color); - --text-codemirror-color: var(--white-color); --text-dropdown-menu-color: var(--white-color); --text-log-viewer-color: var(--white-color); --text-summary-color: var(--white-color); @@ -582,7 +562,6 @@ --border-pre-next-month: var(--white-color); --border-daterangepicker-after: var(--black-color); --border-pre-color: var(--grey-3); - --border-codemirror-cursor-color: var(--white-color); --border-modal: 1px solid var(--white-color); --border-sortbutton: var(--black-color); --border-bootbox: var(--black-color); @@ -596,9 +575,7 @@ --text-input-textarea: var(--black-color); --bg-item-highlighted-null-color: var(--grey-2); - --text-cm-default-color: var(--blue-9); - --text-cm-meta-color: var(--white-color); - --text-cm-string-color: var(--red-7); + --text-progress-bar-color: var(--black-color); --user-menu-icon-color: var(--white-color); diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 48bbfffc6..097b31385 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -36,6 +36,10 @@ border: 1px solid var(--border-input-group-addon-color); } +.input-group .form-control { + z-index: unset; +} + .text-danger { color: var(--ui-error-9); } @@ -150,50 +154,6 @@ code { background-color: var(--bg-table-selected-color); } -.CodeMirror-gutters { - background: var(--bg-codemirror-gutters-color); - border-right: 0px; -} - -.CodeMirror-linenumber { - text-align: left; -} - -.CodeMirror pre.CodeMirror-line, -.CodeMirror pre.CodeMirror-line-like { - padding: 0 20px; -} - -.CodeMirror { - background: var(--bg-codemirror-color); - color: var(--text-codemirror-color); - border-radius: 8px; -} - -.CodeMirror-selected { - background: var(--bg-codemirror-selected-color) !important; -} - -.CodeMirror-cursor { - border-left: 1px solid var(--border-codemirror-cursor-color); -} - -.cm-s-default .cm-atom { - color: var(--text-cm-default-color); -} - -.cm-s-default .cm-meta { - color: var(--text-cm-meta-color); -} - -.cm-s-default .cm-string { - color: var(--text-cm-string-color); -} - -.cm-s-default .cm-number { - color: var(--text-cm-number-color); -} - .dropdown-menu { background: var(--bg-dropdown-menu-color); border-radius: 8px; @@ -358,11 +318,6 @@ input:-webkit-autofill { } /* Overide Vendor CSS */ - -.btn-link:hover { - color: var(--text-link-hover-color) !important; -} - .multiSelect.inlineBlock button { margin: 0; } diff --git a/app/assets/ico/vendor/install-kubernetes.svg b/app/assets/ico/vendor/install-kubernetes.svg new file mode 100644 index 000000000..075065fb6 --- /dev/null +++ b/app/assets/ico/vendor/install-kubernetes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/ico/vendor/kaas-icon.svg b/app/assets/ico/vendor/kaas-icon.svg new file mode 100644 index 000000000..37d427eed --- /dev/null +++ b/app/assets/ico/vendor/kaas-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/docker/__module.js b/app/docker/__module.js index 74e9a80dd..de09c8719 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -1,5 +1,7 @@ import angular from 'angular'; +import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; + import { EnvironmentStatus } from '@/react/portainer/environments/types'; import { reactModule } from './react'; @@ -16,14 +18,17 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ abstract: true, onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService) { return $async(async () => { - if (![1, 2, 4].includes(endpoint.Type)) { + const dockerTypes = [PortainerEndpointTypes.DockerEnvironment, PortainerEndpointTypes.AgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnDockerEnvironment]; + + if (!dockerTypes.includes(endpoint.Type)) { $state.go('portainer.home'); return; } + try { const status = await checkEndpointStatus(endpoint); - if (endpoint.Type !== 4) { + if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) { await updateEndpointStatus(endpoint, status); } endpoint.Status = status; @@ -34,16 +39,22 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ await StateManager.updateEndpointState(endpoint); } catch (e) { - Notifications.error('Failed loading environment', e); - $state.go('portainer.home', {}, { reload: true }); + let params = {}; + + if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) { + params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'docker.dashboard' }; + } else { + Notifications.error('Failed loading environment', e); + } + $state.go('portainer.home', params, { reload: true, inherit: false }); } async function checkEndpointStatus(endpoint) { try { await SystemService.ping(endpoint.Id); - return 1; + return EnvironmentStatus.Up; } catch (e) { - return 2; + return EnvironmentStatus.Down; } } diff --git a/app/docker/components/imageRegistry/por-image-registry.html b/app/docker/components/imageRegistry/por-image-registry.html index 142b3139a..b62036f4d 100644 --- a/app/docker/components/imageRegistry/por-image-registry.html +++ b/app/docker/components/imageRegistry/por-image-registry.html @@ -1,7 +1,7 @@
- +
@@ -37,8 +37,6 @@ model="formValues.RegistryModel" ng-if="formValues.RegistryModel.Registry" auto-complete="true" - label-class="col-sm-1" - input-class="col-sm-11" endpoint="endpoint" is-admin="isAdmin" check-rate-limits="formValues.alwaysPull" @@ -169,7 +167,7 @@
-
+
@@ -106,7 +106,12 @@
- +
diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js index 3fcc4cbb1..b6270ee57 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js @@ -2,10 +2,13 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums'; export default class DockerFeaturesConfigurationController { /* @ngInject */ - constructor($async, $scope, EndpointService, Notifications, StateManager) { + constructor($async, $scope, $state, $analytics, EndpointService, SettingsService, Notifications, StateManager) { this.$async = $async; this.$scope = $scope; + this.$state = $state; + this.$analytics = $analytics; this.EndpointService = EndpointService; + this.SettingsService = SettingsService; this.Notifications = Notifications; this.StateManager = StateManager; @@ -35,6 +38,8 @@ export default class DockerFeaturesConfigurationController { this.save = this.save.bind(this); this.onChangeField = this.onChangeField.bind(this); this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); + this.onToggleGPUManagement = this.onToggleGPUManagement.bind(this); + this.onGpusChange = this.onGpusChange.bind(this); this.onChangeEnableHostManagementFeatures = this.onChangeField('enableHostManagementFeatures'); this.onChangeAllowVolumeBrowserForRegularUsers = this.onChangeField('allowVolumeBrowserForRegularUsers'); this.onChangeDisableBindMountsForRegularUsers = this.onChangeField('disableBindMountsForRegularUsers'); @@ -52,6 +57,12 @@ export default class DockerFeaturesConfigurationController { }); } + onToggleGPUManagement(checked) { + this.$scope.$evalAsync(() => { + this.state.enableGPUManagement = checked; + }); + } + onChange(values) { return this.$scope.$evalAsync(() => { this.formValues = { @@ -69,6 +80,12 @@ export default class DockerFeaturesConfigurationController { }; } + onGpusChange(value) { + return this.$async(async () => { + this.endpoint.Gpus = value; + }); + } + isContainerEditDisabled() { const { disableBindMountsForRegularUsers, @@ -92,7 +109,11 @@ export default class DockerFeaturesConfigurationController { return this.$async(async () => { try { this.state.actionInProgress = true; - const securitySettings = { + + const validGpus = this.endpoint.Gpus.filter((gpu) => gpu.name && gpu.value); + const gpus = this.state.enableGPUManagement ? validGpus : []; + + const settings = { enableHostManagementFeatures: this.formValues.enableHostManagementFeatures, allowBindMountsForRegularUsers: !this.formValues.disableBindMountsForRegularUsers, allowPrivilegedModeForRegularUsers: !this.formValues.disablePrivilegedModeForRegularUsers, @@ -102,33 +123,53 @@ export default class DockerFeaturesConfigurationController { allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers, allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers, allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers, + enableGPUManagement: this.state.enableGPUManagement, + gpus, }; - await this.EndpointService.updateSecuritySettings(this.endpoint.Id, securitySettings); + const publicSettings = await this.SettingsService.publicSettings(); + const analyticsAllowed = publicSettings.EnableTelemetry; + if (analyticsAllowed) { + // send analytics if GPU management is changed (with the new state) + if (this.initialEnableGPUManagement !== this.state.enableGPUManagement) { + this.$analytics.eventTrack('enable-gpu-management-updated', { category: 'portainer', metadata: { enableGPUManagementState: this.state.enableGPUManagement } }); + } + // send analytics if the number of GPUs is changed (with a list of the names) + if (gpus.length > this.initialGPUs.length) { + const numberOfGPUSAdded = this.endpoint.Gpus.length - this.initialGPUs.length; + this.$analytics.eventTrack('gpus-added', { category: 'portainer', metadata: { gpus: gpus.map((gpu) => gpu.name), numberOfGPUSAdded } }); + } + if (gpus.length < this.initialGPUs.length) { + const numberOfGPUSRemoved = this.initialGPUs.length - this.endpoint.Gpus.length; + this.$analytics.eventTrack('gpus-removed', { category: 'portainer', metadata: { gpus: gpus.map((gpu) => gpu.name), numberOfGPUSRemoved } }); + } + this.initialGPUs = gpus; + this.initialEnableGPUManagement = this.state.enableGPUManagement; + } - this.endpoint.SecuritySettings = securitySettings; + await this.EndpointService.updateSecuritySettings(this.endpoint.Id, settings); + + this.endpoint.SecuritySettings = settings; this.Notifications.success('Success', 'Saved settings successfully'); } catch (e) { this.Notifications.error('Failure', e, 'Failed saving settings'); } this.state.actionInProgress = false; + this.$state.reload(); }); } - checkAgent() { - const applicationState = this.StateManager.getState(); - return applicationState.endpoint.mode.agentProxy; - } - $onInit() { const securitySettings = this.endpoint.SecuritySettings; - const isAgent = this.checkAgent(); - this.isAgent = isAgent; + const applicationState = this.StateManager.getState(); + this.isAgent = applicationState.endpoint.mode.agentProxy; + + this.isDockerStandaloneEnv = applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'; this.formValues = { - enableHostManagementFeatures: isAgent && securitySettings.enableHostManagementFeatures, - allowVolumeBrowserForRegularUsers: isAgent && securitySettings.allowVolumeBrowserForRegularUsers, + enableHostManagementFeatures: this.isAgent && securitySettings.enableHostManagementFeatures, + allowVolumeBrowserForRegularUsers: this.isAgent && securitySettings.allowVolumeBrowserForRegularUsers, disableBindMountsForRegularUsers: !securitySettings.allowBindMountsForRegularUsers, disablePrivilegedModeForRegularUsers: !securitySettings.allowPrivilegedModeForRegularUsers, disableHostNamespaceForRegularUsers: !securitySettings.allowHostNamespaceForRegularUsers, @@ -137,5 +178,11 @@ export default class DockerFeaturesConfigurationController { disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers, disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers, }; + + // this.endpoint.Gpus could be null as it is Gpus: []Pair in the API + this.endpoint.Gpus = this.endpoint.Gpus || []; + this.state.enableGPUManagement = this.isDockerStandaloneEnv && (this.endpoint.EnableGPUManagement || this.endpoint.Gpus.length > 0); + this.initialGPUs = this.endpoint.Gpus; + this.initialEnableGPUManagement = this.endpoint.EnableGPUManagement; } } diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html index 3e93ac720..db3c6d697 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.html +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html @@ -150,9 +150,28 @@
Other
+
+ +
+
+
+
+ +
+
+
+ Actions
- diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index 5862059cb..c07cf90ed 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -167,6 +167,6 @@ function confirmImageForceRemoval() { title: 'Are you sure?', modalType: ModalType.Destructive, message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.', - confirmButton: buildConfirmButton('Remote the image', 'danger'), + confirmButton: buildConfirmButton('Remove the image', 'danger'), }); } diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html index 942babdce..436f393c7 100644 --- a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html @@ -1,19 +1,5 @@
-
Edge Groups
-
-
- -
- -

- There are no available deployment types when there is more than one type of environment in your edge group - selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type. -

-

- Edge groups with kubernetes environments no longer support compose deployment types in Portainer. Please select - edge groups that only have docker environments when using compose deployment types. -

-
+ +
diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js index 253c7ea66..7086570a7 100644 --- a/app/edge/components/group-form/groupFormController.js +++ b/app/edge/components/group-form/groupFormController.js @@ -37,6 +37,7 @@ export class EdgeGroupFormController { this.onChangeDynamic = this.onChangeDynamic.bind(this); this.onChangeModel = this.onChangeModel.bind(this); this.onChangePartialMatch = this.onChangePartialMatch.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); $scope.$watch( () => this.model, @@ -118,6 +119,10 @@ export class EdgeGroupFormController { }); } + handleSubmit() { + this.formAction(this.model); + } + $onInit() { this.getTags(); } diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 3eee3f259..1fe7e475b 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -7,12 +7,19 @@ import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInInt import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm'; import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector'; +import { withUIRouter } from '@/react-tools/withUIRouter'; export const componentsModule = angular .module('portainer.edge.react.components', []) .component( 'edgeGroupsSelector', - r2a(EdgeGroupsSelector, ['items', 'onChange', 'value']) + r2a(withUIRouter(withReactQuery(EdgeGroupsSelector)), [ + 'onChange', + 'value', + 'error', + 'horizontal', + 'isGroupVisible', + ]) ) .component( 'edgeScriptForm', @@ -21,6 +28,7 @@ export const componentsModule = angular 'commands', 'isNomadTokenVisible', 'asyncMode', + 'showMetaFields', ]) ) .component( diff --git a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js index a1172b836..c82a63f1d 100644 --- a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js +++ b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js @@ -21,7 +21,6 @@ export class CreateEdgeGroupController { }; this.createGroup = this.createGroup.bind(this); - this.createGroupAsync = this.createGroupAsync.bind(this); } async $onInit() { @@ -31,20 +30,18 @@ export class CreateEdgeGroupController { this.state.loaded = true; } - createGroup() { - return this.$async(this.createGroupAsync); - } - - async createGroupAsync() { - this.state.actionInProgress = true; - try { - await this.EdgeGroupService.create(this.model); - this.Notifications.success('Success', 'Edge group successfully created'); - this.$state.go('edge.groups'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to create edge group'); - } finally { - this.state.actionInProgress = false; - } + async createGroup(model) { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.create(model); + this.Notifications.success('Success', 'Edge group successfully created'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create edge group'); + } finally { + this.state.actionInProgress = false; + } + }); } } diff --git a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js index feb06b2c2..3838206ca 100644 --- a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js +++ b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js @@ -13,7 +13,6 @@ export class EditEdgeGroupController { }; this.updateGroup = this.updateGroup.bind(this); - this.updateGroupAsync = this.updateGroupAsync.bind(this); } async $onInit() { @@ -28,20 +27,18 @@ export class EditEdgeGroupController { this.state.loaded = true; } - updateGroup() { - return this.$async(this.updateGroupAsync); - } - - async updateGroupAsync() { - this.state.actionInProgress = true; - try { - await this.EdgeGroupService.update(this.model); - this.Notifications.success('Success', 'Edge group successfully updated'); - this.$state.go('edge.groups'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to update edge group'); - } finally { - this.state.actionInProgress = false; - } + updateGroup(group) { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.update(group); + this.Notifications.success('Success', 'Edge group successfully updated'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update edge group'); + } finally { + this.state.actionInProgress = false; + } + }); } } diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js index 1eae9715d..c30ece1a8 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js @@ -86,7 +86,6 @@ export default class CreateEdgeStackViewController { async $onInit() { try { this.edgeGroups = await this.EdgeGroupService.groups(); - this.noGroups = this.edgeGroups.length === 0; } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); } diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html index ecfa4dc84..0c406ad96 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html @@ -39,19 +39,7 @@
-
Edge Groups
-
-
- -
-
- No Edge groups are available. Head over to the Edge groups view to create one. -
-

- There are no available deployment types when there is more than one type of environment in your edge - group selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type. -

-
+ { - if (![5, 6, 7].includes(endpoint.Type)) { + const kubeTypes = [ + PortainerEndpointTypes.KubernetesLocalEnvironment, + PortainerEndpointTypes.AgentOnKubernetesEnvironment, + PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment, + ]; + + if (!kubeTypes.includes(endpoint.Type)) { $state.go('portainer.home'); return; } try { - if (endpoint.Type === 7) { + if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) { //edge try { await KubernetesHealthService.ping(endpoint.Id); - endpoint.Status = 1; + endpoint.Status = EnvironmentStatus.Up; } catch (e) { - endpoint.Status = 2; + endpoint.Status = EnvironmentStatus.Down; } } await StateManager.updateEndpointState(endpoint); - if (endpoint.Type === 7 && endpoint.Status === 2) { + if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) { throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'); } - await KubernetesNamespaceService.get(); + // use selfsubject access review to check if we can connect to the kubernetes environment + // because it's gets a fast response, and is accessible to all users + try { + await getSelfSubjectAccessReview(endpoint.Id, 'default'); + } catch (e) { + throw new Error('Environment is unreachable.'); + } } catch (e) { - Notifications.error('Failed loading environment', e); - $state.go('portainer.home', {}, { reload: true }); + let params = {}; + + if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) { + params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'kubernetes.dashboard' }; + } else { + Notifications.error('Failed loading environment', e); + } + $state.go('portainer.home', params, { reload: true, inherit: false }); } }); }, diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html index f41c7dfaa..d5465a6db 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html @@ -1,116 +1,144 @@
-
-
+
+
Applications
- -
- - - -
-
- - - - -