Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f42ba0254 | ||
|
|
6f81fcc169 | ||
|
|
46949508a4 | ||
|
|
034157be9a | ||
|
|
011a1ce720 | ||
|
|
a4922eb693 | ||
|
|
8c77c5ffbe | ||
|
|
a062c36ff5 | ||
|
|
122fd835dc | ||
|
|
f7ff07833f | ||
|
|
8010167006 | ||
|
|
4c79e9ef6b | ||
|
|
88ea0cb64f | ||
|
|
5f50f20a7a | ||
|
|
bbc26682dd | ||
|
|
f74704fca4 | ||
|
|
9b52bd50d9 | ||
|
|
04073f0d1f | ||
|
|
c035e4a778 |
@@ -30,6 +30,7 @@ var filesToBackup = []string{
|
||||
"portainer.key",
|
||||
"portainer.pub",
|
||||
"tls",
|
||||
"chisel",
|
||||
}
|
||||
|
||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||
|
||||
@@ -157,6 +157,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
return store
|
||||
}
|
||||
|
||||
// checkDBSchemaServerVersionMatch checks if the server version matches the db scehma version
|
||||
func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersion string, serverEdition int) bool {
|
||||
v, err := dbStore.Version().Version()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||
}
|
||||
|
||||
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
||||
if err != nil {
|
||||
@@ -388,6 +398,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
// check if the db schema version matches with server version
|
||||
if !checkDBSchemaServerVersionMatch(dataStore, portainer.APIVersion, int(portainer.Edition)) {
|
||||
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
|
||||
}
|
||||
|
||||
instanceID, err := dataStore.Version().InstanceID()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed getting instance id")
|
||||
|
||||
@@ -51,9 +51,9 @@ func (store *Store) MigrateData() error {
|
||||
err = errors.Wrap(err, "failed to migrate database")
|
||||
|
||||
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
|
||||
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to restore database")
|
||||
restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
|
||||
if restorErr != nil {
|
||||
return errors.Wrap(restorErr, "failed to restore database")
|
||||
}
|
||||
|
||||
log.Info().Msg("database restored to previous version")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
portaineree "github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
@@ -72,7 +72,7 @@ func dbVersionToSemanticVersion(dbVersion int) string {
|
||||
func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
|
||||
// Very old versions of portainer did not have a version bucket, lets set some defaults
|
||||
dbVersion := 24
|
||||
edition := int(portaineree.PortainerCE)
|
||||
edition := int(portainer.PortainerCE)
|
||||
instanceId := ""
|
||||
|
||||
// If we already have a version key, we don't need to migrate
|
||||
|
||||
@@ -944,6 +944,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package customtemplates
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -472,3 +473,29 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
|
||||
|
||||
return customTemplate, nil
|
||||
}
|
||||
|
||||
// @id CustomTemplateCreate
|
||||
// @summary Create a custom template
|
||||
// @description Create a custom template.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags custom_templates
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json,multipart/form-data
|
||||
// @produce json
|
||||
// @param method query string true "method for creating template" Enums(string, file, repository)
|
||||
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
|
||||
// @success 200 {object} portainer.CustomTemplate
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /custom_templates [post]
|
||||
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("/custom_templates/create/%s", method)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
|
||||
h.Handle("/custom_templates/create/{method}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/custom_templates",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
|
||||
h.Handle("/custom_templates/{id}",
|
||||
|
||||
@@ -2,6 +2,7 @@ package edgejobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -287,3 +288,26 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
|
||||
|
||||
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
|
||||
}
|
||||
|
||||
// @id EdgeJobCreate
|
||||
// @summary Create an EdgeJob
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_jobs
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param method query string true "Creation Method" Enums(file, string)
|
||||
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
|
||||
// @success 200 {object} portainer.EdgeGroup
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @failure 500
|
||||
// @deprecated
|
||||
// @router /edge_jobs [post]
|
||||
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/edge_jobs/create/%s", method), nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -29,6 +30,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
h.Handle("/edge_jobs",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_jobs",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_jobs/create/{method}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_jobs/{id}",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -18,6 +19,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: method", err)
|
||||
}
|
||||
|
||||
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
@@ -60,3 +62,26 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
|
||||
|
||||
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||
}
|
||||
|
||||
// @id EdgeStackCreate
|
||||
// @summary Create an EdgeStack
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param method query string true "Creation Method" Enums(file,string,repository)
|
||||
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @deprecated
|
||||
// @router /edge_stacks [post]
|
||||
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/edge_stacks/create/%s", method), nil
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
|
||||
h.Handle("/edge_stacks/create/{method}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_stacks/{id}",
|
||||
|
||||
@@ -84,7 +84,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.19.0
|
||||
// @version 2.19.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -58,6 +59,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
h.Handle("/stacks/create/{type}/{method}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/stacks",
|
||||
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/stacks",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
|
||||
h.Handle("/stacks/{id}",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -139,3 +140,53 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
|
||||
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch stackType {
|
||||
case 1:
|
||||
return "swarm", nil
|
||||
case 2:
|
||||
return "standalone", nil
|
||||
case 3:
|
||||
return "kubernetes", nil
|
||||
}
|
||||
|
||||
return "", errors.New(request.ErrInvalidQueryParameter)
|
||||
}
|
||||
|
||||
// @id StackCreate
|
||||
// @summary Deploy a new stack
|
||||
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json,multipart/form-data
|
||||
// @produce json
|
||||
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
|
||||
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
|
||||
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
|
||||
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
|
||||
// @success 200 {object} portainer.Stack
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /stacks [post]
|
||||
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
stackType, err := getStackTypeFromQueryParameter(r)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: type", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
|
||||
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
|
||||
errCryptoHashFailure = errors.New("Unable to hash data")
|
||||
errWrongPassword = errors.New("Wrong password")
|
||||
)
|
||||
|
||||
func hideFields(user *portainer.User) {
|
||||
|
||||
@@ -21,9 +21,10 @@ type themePayload struct {
|
||||
}
|
||||
|
||||
type userUpdatePayload struct {
|
||||
Username string `validate:"required" example:"bob"`
|
||||
Password string `validate:"required" example:"cg9Wgky3"`
|
||||
Theme *themePayload
|
||||
Username string `validate:"required" example:"bob"`
|
||||
Password string `validate:"required" example:"cg9Wgky3"`
|
||||
NewPassword string `validate:"required" example:"asfj2emv"`
|
||||
Theme *themePayload
|
||||
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
Role int `validate:"required" enums:"1,2" example:"2"`
|
||||
@@ -37,12 +38,14 @@ func (payload *userUpdatePayload) Validate(r *http.Request) error {
|
||||
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 nil
|
||||
}
|
||||
|
||||
// @id UserUpdate
|
||||
// @summary Update a user
|
||||
// @description Update user details. A regular user account can only update his details.
|
||||
// @description A regular user account cannot change their username or role.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
@@ -95,6 +98,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
}
|
||||
|
||||
if payload.Username != "" && payload.Username != user.Username {
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied)
|
||||
}
|
||||
|
||||
sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username)
|
||||
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
@@ -106,8 +113,28 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
user.Username = payload.Username
|
||||
}
|
||||
|
||||
if payload.Password != "" {
|
||||
user.Password, err = handler.CryptoService.Hash(payload.Password)
|
||||
if payload.Password != "" && payload.NewPassword == "" {
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request"))
|
||||
}
|
||||
|
||||
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request"))
|
||||
}
|
||||
|
||||
if payload.NewPassword != "" {
|
||||
// Non-admins need to supply the previous password
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again"))
|
||||
}
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
return httperror.BadRequest("Password does not meet the requirements", nil)
|
||||
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
|
||||
|
||||
25
api/http/middlewares/deprecated.go
Normal file
25
api/http/middlewares/deprecated.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// deprecate api route
|
||||
func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
newUrl, err := urlBuilder(w, r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, err.StatusCode, err.Error(), err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl)
|
||||
|
||||
redirectedRequest := r.Clone(r.Context())
|
||||
redirectedRequest.URL.Path = newUrl
|
||||
router.ServeHTTP(w, redirectedRequest)
|
||||
})
|
||||
}
|
||||
@@ -100,6 +100,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
|
||||
endpointGroup := getAssociatedGroup(&endpoint, groups)
|
||||
|
||||
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
|
||||
endpoint.UserAccessPolicies = nil
|
||||
endpoints[n] = endpoint
|
||||
n++
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -154,17 +155,29 @@ func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.En
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateClient returns a pointer to a new Clientset instance
|
||||
// CreateClient returns a pointer to a new Clientset instance.
|
||||
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
switch endpoint.Type {
|
||||
case portainer.KubernetesLocalEnvironment:
|
||||
return buildLocalClient()
|
||||
case portainer.AgentOnKubernetesEnvironment:
|
||||
return factory.buildAgentClient(endpoint)
|
||||
case portainer.EdgeAgentOnKubernetesEnvironment:
|
||||
return factory.buildEdgeClient(endpoint)
|
||||
case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment:
|
||||
c, err := factory.CreateConfig(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kubernetes.NewForConfig(c)
|
||||
}
|
||||
return nil, errors.New("unsupported environment type")
|
||||
}
|
||||
|
||||
// CreateConfig returns a pointer to a new kubeconfig ready to create a client.
|
||||
func (factory *ClientFactory) CreateConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
|
||||
switch endpoint.Type {
|
||||
case portainer.KubernetesLocalEnvironment:
|
||||
return buildLocalConfig()
|
||||
case portainer.AgentOnKubernetesEnvironment:
|
||||
return factory.buildAgentConfig(endpoint)
|
||||
case portainer.EdgeAgentOnKubernetesEnvironment:
|
||||
return factory.buildEdgeConfig(endpoint)
|
||||
}
|
||||
return nil, errors.New("unsupported environment type")
|
||||
}
|
||||
|
||||
@@ -184,20 +197,64 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
|
||||
return rt.roundTripper.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
|
||||
func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
|
||||
var clientURL strings.Builder
|
||||
if !strings.HasPrefix(endpoint.URL, "http") {
|
||||
clientURL.WriteString("https://")
|
||||
}
|
||||
clientURL.WriteString(endpoint.URL)
|
||||
clientURL.WriteString("/kubernetes")
|
||||
|
||||
return factory.createRemoteClient(endpointURL)
|
||||
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := clientcmd.BuildConfigFromFlags(clientURL.String(), "")
|
||||
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{
|
||||
signatureHeader: signature,
|
||||
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
|
||||
roundTripper: rt,
|
||||
}
|
||||
})
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
|
||||
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed activating tunnel")
|
||||
}
|
||||
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
|
||||
|
||||
return factory.createRemoteClient(endpointURL)
|
||||
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
config.Insecure = true
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
|
||||
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &agentHeaderRoundTripper{
|
||||
signatureHeader: signature,
|
||||
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
|
||||
roundTripper: rt,
|
||||
}
|
||||
})
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
|
||||
@@ -227,34 +284,14 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
|
||||
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
|
||||
|
||||
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
config, err := factory.CreateConfig(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to create metrics KubeConfig")
|
||||
}
|
||||
|
||||
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
|
||||
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{
|
||||
signatureHeader: signature,
|
||||
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
|
||||
roundTripper: rt,
|
||||
}
|
||||
})
|
||||
|
||||
return metricsv.NewForConfig(config)
|
||||
}
|
||||
|
||||
func buildLocalClient() (*kubernetes.Clientset, error) {
|
||||
func buildLocalConfig() (*rest.Config, error) {
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -263,7 +300,7 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
|
||||
return kubernetes.NewForConfig(config)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
|
||||
|
||||
@@ -1559,7 +1559,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.19.0"
|
||||
APIVersion = "2.19.1"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -17,6 +17,18 @@ type Scheduler struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type PermanentError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func NewPermanentError(err error) *PermanentError {
|
||||
return &PermanentError{err: err}
|
||||
}
|
||||
|
||||
func (e *PermanentError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func NewScheduler(ctx context.Context) *Scheduler {
|
||||
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
|
||||
crontab.Start()
|
||||
@@ -84,14 +96,24 @@ func (s *Scheduler) StopJob(jobID string) error {
|
||||
func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
j := cron.FuncJob(func() {
|
||||
if err := job(); err != nil {
|
||||
log.Debug().Msg("job returned an error")
|
||||
cancel()
|
||||
jobFn := cron.FuncJob(func() {
|
||||
err := job()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var permErr *PermanentError
|
||||
if errors.As(err, &permErr) {
|
||||
log.Error().Err(permErr).Msg("job returned a permanent error, it will be stopped")
|
||||
cancel()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Error().Err(err).Msg("job returned an error, it will be rescheduled")
|
||||
})
|
||||
|
||||
entryID := s.crontab.Schedule(cron.Every(duration), j)
|
||||
entryID := s.crontab.Schedule(cron.Every(duration), jobFn)
|
||||
|
||||
s.mu.Lock()
|
||||
s.activeJobs[entryID] = cancel
|
||||
|
||||
@@ -49,7 +49,7 @@ func Test_JobCanBeStopped(t *testing.T) {
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
}
|
||||
|
||||
func Test_JobShouldStop_UponError(t *testing.T) {
|
||||
func Test_JobShouldStop_UponPermError(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer s.Shutdown()
|
||||
|
||||
@@ -58,7 +58,7 @@ func Test_JobShouldStop_UponError(t *testing.T) {
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
acc++
|
||||
close(ch)
|
||||
return fmt.Errorf("failed")
|
||||
return NewPermanentError(fmt.Errorf("failed"))
|
||||
})
|
||||
|
||||
<-time.After(3 * jobInterval)
|
||||
@@ -66,6 +66,28 @@ func Test_JobShouldStop_UponError(t *testing.T) {
|
||||
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
|
||||
}
|
||||
|
||||
func Test_JobShouldNotStop_UponError(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer s.Shutdown()
|
||||
|
||||
var acc int
|
||||
ch := make(chan struct{})
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
acc++
|
||||
|
||||
if acc == 2 {
|
||||
close(ch)
|
||||
return NewPermanentError(fmt.Errorf("failed"))
|
||||
}
|
||||
|
||||
return errors.New("non-permanent error")
|
||||
})
|
||||
|
||||
<-time.After(3 * jobInterval)
|
||||
<-ch
|
||||
assert.Equal(t, 2, acc)
|
||||
}
|
||||
|
||||
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -29,7 +30,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
|
||||
|
||||
stack, err := datastore.Stack().Read(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
|
||||
}
|
||||
|
||||
@@ -38,7 +41,15 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
}
|
||||
|
||||
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(
|
||||
errors.WithMessagef(err,
|
||||
"failed to find the environment %v associated to the stack %v",
|
||||
stack.EndpointID,
|
||||
stack.ID,
|
||||
),
|
||||
)
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
|
||||
}
|
||||
|
||||
@@ -78,7 +89,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
}
|
||||
|
||||
registries, err := getUserRegistries(datastore, user, endpoint.ID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(err)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
checked="ctrl.formValues.RestrictStandardUserIngressW"
|
||||
name="'restrictStandardUserIngressW'"
|
||||
label="'Only allow admins to deploy ingresses'"
|
||||
feature-id="ctrl.limitedFeatureIngressDeploy"
|
||||
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
|
||||
on-change="(ctrl.onToggleRestrictStandardUserIngressW)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
|
||||
@@ -6,10 +6,11 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
|
||||
|
||||
class PorAccessManagementController {
|
||||
/* @ngInject */
|
||||
constructor($scope, Notifications, AccessService, RoleService) {
|
||||
Object.assign(this, { $scope, Notifications, AccessService, RoleService });
|
||||
constructor($scope, $state, Notifications, AccessService, RoleService) {
|
||||
Object.assign(this, { $scope, $state, Notifications, AccessService, RoleService });
|
||||
|
||||
this.limitedToBE = false;
|
||||
this.$state = $state;
|
||||
|
||||
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
|
||||
this.updateAction = this.updateAction.bind(this);
|
||||
@@ -105,6 +106,7 @@ class PorAccessManagementController {
|
||||
this.availableUsersAndTeams = _.orderBy(data.availableUsersAndTeams, 'Name', 'asc');
|
||||
this.authorizedUsersAndTeams = data.authorizedUsersAndTeams;
|
||||
} catch (err) {
|
||||
this.$state.go('portainer.home');
|
||||
this.availableUsersAndTeams = [];
|
||||
this.authorizedUsersAndTeams = [];
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve accesses');
|
||||
|
||||
@@ -54,8 +54,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||
return Users.remove({ id: id }).$promise;
|
||||
};
|
||||
|
||||
service.updateUser = function (id, { password, role, username }) {
|
||||
return Users.update({ id }, { password, role, username }).$promise;
|
||||
service.updateUser = function (id, { newPassword, role, username }) {
|
||||
return Users.update({ id }, { newPassword, role, username }).$promise;
|
||||
};
|
||||
|
||||
service.updateUserPassword = function (id, currentPassword, newPassword) {
|
||||
|
||||
@@ -49,7 +49,7 @@ export default class LdapSettingsBaseDnBuilderController {
|
||||
const [, type, value] = match;
|
||||
ouValues.push({ type, value });
|
||||
left = left.replace(regex, '');
|
||||
match = left.match(/(\w+)=(\w+),?/);
|
||||
match = left.match(regex);
|
||||
}
|
||||
return ouValues;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ angular.module('portainer.app').controller('UserController', [
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
UserService.updateUser($scope.user.Id, { password: $scope.formValues.newPassword })
|
||||
UserService.updateUser($scope.user.Id, { newPassword: $scope.formValues.newPassword })
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Password successfully updated');
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import { ComponentProps } from 'react';
|
||||
import uuid from 'uuid';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
@@ -30,7 +31,7 @@ export function SwitchField({
|
||||
checked,
|
||||
label,
|
||||
index,
|
||||
name,
|
||||
name = uuid(),
|
||||
labelClass,
|
||||
fieldClass,
|
||||
dataCy,
|
||||
@@ -43,15 +44,16 @@ export function SwitchField({
|
||||
const toggleName = name ? `toggle_${name}` : '';
|
||||
|
||||
return (
|
||||
<label className={clsx(styles.root, fieldClass)}>
|
||||
<span
|
||||
<div className={clsx(styles.root, fieldClass)}>
|
||||
<label
|
||||
className={clsx('space-right control-label !p-0 text-left', labelClass)}
|
||||
htmlFor={toggleName}
|
||||
>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<Tooltip message={tooltip} setHtmlMessage={setTooltipHtmlMessage} />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
className={clsx('space-right', switchClass)}
|
||||
name={toggleName}
|
||||
@@ -63,6 +65,6 @@ export function SwitchField({
|
||||
featureId={featureId}
|
||||
dataCy={dataCy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,6 +436,7 @@ export function IngressForm({
|
||||
}
|
||||
noOptionsMessage={() => 'No TLS secrets available'}
|
||||
size="sm"
|
||||
options={tlsOptions}
|
||||
/>
|
||||
{!host.NoHost && (
|
||||
<div className="input-group-btn">
|
||||
|
||||
@@ -197,17 +197,22 @@ function useAuthorizedTeams(authorizedTeamIds: TeamId[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function useAuthorizedUsers(authorizedUserIds: UserId[]) {
|
||||
return useUsers(false, 0, authorizedUserIds.length > 0, (users) => {
|
||||
if (authorizedUserIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
function useAuthorizedUsers(authorizedUserIds: UserId[], enabled = true) {
|
||||
return useUsers(
|
||||
false,
|
||||
0,
|
||||
authorizedUserIds.length > 0 && enabled,
|
||||
(users) => {
|
||||
if (authorizedUserIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.compact(
|
||||
authorizedUserIds.map((id) => {
|
||||
const user = users.find((u) => u.Id === id);
|
||||
return user?.Username;
|
||||
})
|
||||
);
|
||||
});
|
||||
return _.compact(
|
||||
authorizedUserIds.map((id) => {
|
||||
const user = users.find((u) => u.Id === id);
|
||||
return user?.Username;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export function EditDetails({
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const { users, teams, isLoading } = useLoadState(environmentId);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
onChange({ ...values, ...partialValues });
|
||||
|
||||
@@ -4,7 +4,6 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export function useLoadState(environmentId?: EnvironmentId) {
|
||||
const teams = useTeams(false, environmentId);
|
||||
|
||||
const users = useUsers(false, environmentId);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -57,7 +57,8 @@ func (hbpm *helmBinaryPackageManager) SearchRepo(searchRepoOpts options.SearchRe
|
||||
// I'm seeing 3 - 4s over wifi.
|
||||
// Give ample time but timeout for now. Can be improved in the future
|
||||
client = &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user