feat(k8s): support git automated sync for k8s applications [EE-577] (#5548)

* feat(stack): backport changes to CE EE-1189

* feat(stack): front end backport changes to CE EE-1199 (#5455)

* feat(stack): front end backport changes to CE EE-1199

* fix k8s deploy logic

* fixed web editor confirmation message typo. EE-1501

* fix(stack): fixed issue auth detail not remembered EE-1502 (#5459)

* show status in buttons

* removed onChangeRef function.

* moved buttons in git form to its own component

* removed unused variable.

Co-authored-by: ArrisLee <arris_li@hotmail.com>

* moved formvalue to kube app component

* fix(stack): failed to pull and redeploy compose format k8s stack

* fixed form value

* fix(k8s): file content overridden when deployment failed with compose format EE-1548

* updated API response to get IsComposeFormat and show appropriate text.

* feat(k8s): front end backport to CE

* feat(kube): kube app auto update backend (#5547)

* error message updates for different file type

* not display creation source for external application

* added confirmation modal to advanced app created by web editor

* stop showing confirmation modal when updating application

* disable rollback button when application type is not applicatiom form

* only update file after deployment succeded

* Revert "only update file after deployment succeded"

This reverts commit b94bd2e96f.

* fix(k8s): file content overridden when deployment failed with compose format EE-1556

* added analytics-on directive to pull and redeploy button

* fix(kube): don't valide resource control access for kube (#5568)

* added missing question mark to k8s confirmation modal

* fixed webhook format issue

* added question marks to k8s app confirmation modal

* added space in additional file list.

* ignoring error on deletion

* fix(k8s): Git authentication info not persisted

* added RepositoryMechanismTypes constant

* updated analytics functions

* covert RepositoryMechanism to constant

* fixed typo

* removed unused function.

* post tech review updates

* fixed save settings n redeploy button

* refact kub deploy logic

* Revert "refact kub deploy logic"

This reverts commit cbfdd58ece.

* feat(k8s): utilize user token for k8s auto update EE-1594

* feat(k8s): persist kub stack name EE-1630

* feat(k8s): support delete kub stack

* fix(app): updated logic to delete stack for different kind apps. (#5648)

* fix(app): updated logic to delete stack for different kind apps.

* renamed variable

* fix import

* added StackName field.

* fixed stack id not found issue.

* fix(k8s): fixed qusetion mark alignment issue in PAT field. (#5611)

* fix(k8s): fixed qusetion mark alignment issue in PAT field.

* moved inline css to file.

* fix(git-form: made auth input text full width

* add ignore deleted arg

* tech review updates

* typo fix

* fix(k8s): added console error when deleting k8s service.

* fix(console): added no-console config

* fix(deploy): added missing service.

* fix: use stack editor as an owner when exists (#5678)

* fix: tempalte/content based stacks edit/delete

* fix(stack): remove stack when no app. (#5769)

* fix(stack): remove stack when no app.

* support compose format in delete

Co-authored-by: ArrisLee <arris_li@hotmail.com>

Co-authored-by: Hui <arris_li@hotmail.com>
Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
pull/5643/head
Dmitry Salakhov 2021-09-30 12:58:10 +13:00 committed by GitHub
parent fce885901f
commit 2ecc8ab5c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1193 additions and 570 deletions

View File

@ -553,7 +553,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
} }
scheduler := scheduler.NewScheduler(shutdownCtx) scheduler := scheduler.NewScheduler(shutdownCtx)
stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager) stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer)
stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService) stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
return &http.Server{ return &http.Server{

View File

@ -47,7 +47,7 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint) url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to fetch endpoint proxy") return errors.Wrap(err, "failed to fetch environment proxy")
} }
if proxy != nil { if proxy != nil {
@ -80,7 +80,7 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
} }
// NormalizeStackName returns a new stack name with unsupported characters replaced // NormalizeStackName returns a new stack name with unsupported characters replaced
func (w *ComposeStackManager) NormalizeStackName(name string) string { func (manager *ComposeStackManager) NormalizeStackName(name string) string {
r := regexp.MustCompile("[^a-z0-9]+") r := regexp.MustCompile("[^a-z0-9]+")
return r.ReplaceAllString(strings.ToLower(name), "") return r.ReplaceAllString(strings.ToLower(name), "")
} }

View File

@ -1,8 +1,6 @@
package exectest package exectest
import ( import (
"net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
@ -12,7 +10,11 @@ func NewKubernetesDeployer() portainer.KubernetesDeployer {
return &kubernetesMockDeployer{} return &kubernetesMockDeployer{}
} }
func (deployer *kubernetesMockDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, data string, namespace string) (string, error) { func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil
}
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return "", nil return "", nil
} }

View File

@ -3,7 +3,6 @@ package exec
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"net/http"
"os/exec" "os/exec"
"path" "path"
"runtime" "runtime"
@ -13,7 +12,6 @@ import (
"github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory" "github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/kubernetes/cli"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -43,12 +41,7 @@ func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheMan
} }
} }
func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) { func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return "", err
}
kubeCLI, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint) kubeCLI, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil { if err != nil {
return "", err return "", err
@ -61,11 +54,16 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po
return "", err return "", err
} }
if tokenData.Role == portainer.AdministratorRole { user, err := deployer.dataStore.User().User(userID)
if err != nil {
return "", errors.Wrap(err, "failed to fetch the user")
}
if user.Role == portainer.AdministratorRole {
return tokenManager.GetAdminServiceAccountToken(), nil return tokenManager.GetAdminServiceAccountToken(), nil
} }
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID) token, err := tokenManager.GetUserServiceAccountToken(int(user.ID), endpoint.ID)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -76,15 +74,31 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po
return token, nil return token, nil
} }
// Deploy will deploy a Kubernetes manifest inside an optional namespace in a Kubernetes environment(endpoint). // Deploy upserts Kubernetes resources defined in manifest(s)
// Otherwise it will use kubectl to deploy the manifest. func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) { return deployer.command("apply", userID, endpoint, manifestFiles, namespace)
}
// Remove deletes Kubernetes resources defined in manifest(s)
func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
return deployer.command("delete", userID, endpoint, manifestFiles, namespace)
}
func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
token, err := deployer.getToken(userID, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment)
if err != nil {
return "", errors.Wrap(err, "failed generating a user token")
}
command := path.Join(deployer.binaryPath, "kubectl") command := path.Join(deployer.binaryPath, "kubectl")
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
command = path.Join(deployer.binaryPath, "kubectl.exe") command = path.Join(deployer.binaryPath, "kubectl.exe")
} }
args := make([]string, 0) args := []string{"--token", token}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
url, proxy, err := deployer.getAgentURL(endpoint) url, proxy, err := deployer.getAgentURL(endpoint)
@ -97,21 +111,18 @@ func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *port
args = append(args, "--insecure-skip-tls-verify") args = append(args, "--insecure-skip-tls-verify")
} }
token, err := deployer.getToken(request, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment) if operation == "delete" {
if err != nil { args = append(args, "--ignore-not-found=true")
return "", err
} }
args = append(args, "--token", token) args = append(args, operation)
if namespace != "" { for _, path := range manifestFiles {
args = append(args, "--namespace", namespace) args = append(args, "-f", strings.TrimSpace(path))
} }
args = append(args, "apply", "-f", "-")
var stderr bytes.Buffer var stderr bytes.Buffer
cmd := exec.Command(command, args...) cmd := exec.Command(command, args...)
cmd.Stderr = &stderr cmd.Stderr = &stderr
cmd.Stdin = strings.NewReader(stackConfig)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {

23
api/filesystem/write.go Normal file
View File

@ -0,0 +1,23 @@
package filesystem
import (
"os"
"path/filepath"
"github.com/pkg/errors"
)
func WriteToFile(dst string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
return errors.Wrapf(err, "failed to create filestructure for the path %q", dst)
}
file, err := os.Create(dst)
if err != nil {
return errors.Wrapf(err, "failed to open a file %q", dst)
}
defer file.Close()
_, err = file.Write(content)
return errors.Wrapf(err, "failed to write a file %q", dst)
}

View File

@ -0,0 +1,48 @@
package filesystem
import (
"io/ioutil"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
tmpDir := t.TempDir()
tmpFilePath := path.Join(tmpDir, "dummy")
content := []byte("content")
err := WriteToFile(tmpFilePath, content)
assert.NoError(t, err)
fileContent, _ := ioutil.ReadFile(tmpFilePath)
assert.Equal(t, content, fileContent)
}
func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) {
tmpDir := t.TempDir()
tmpFilePath := path.Join(tmpDir, "dummy")
err := WriteToFile(tmpFilePath, []byte("content"))
assert.NoError(t, err)
content := []byte("new content")
err = WriteToFile(tmpFilePath, content)
assert.NoError(t, err)
fileContent, _ := ioutil.ReadFile(tmpFilePath)
assert.Equal(t, content, fileContent)
}
func Test_WriteFile_CanWriteANestedPath(t *testing.T) {
tmpDir := t.TempDir()
tmpFilePath := path.Join(tmpDir, "dir", "sub-dir", "dummy")
content := []byte("content")
err := WriteToFile(tmpFilePath, content)
assert.NoError(t, err)
fileContent, _ := ioutil.ReadFile(tmpFilePath)
assert.Equal(t, content, fileContent)
}

View File

@ -2,6 +2,7 @@ package helm
import ( import (
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@ -182,6 +183,11 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte,
return errors.Wrap(err, "unable to find an endpoint on request context") return errors.Wrap(err, "unable to find an endpoint on request context")
} }
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return errors.Wrap(err, "unable to retrieve user details from authentication token")
}
// extract list of yaml resources from helm manifest // extract list of yaml resources from helm manifest
yamlResources, err := kubernetes.ExtractDocuments(manifest, nil) yamlResources, err := kubernetes.ExtractDocuments(manifest, nil)
if err != nil { if err != nil {
@ -193,6 +199,19 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte,
for _, resource := range yamlResources { for _, resource := range yamlResources {
resource := resource // https://golang.org/doc/faq#closures_and_goroutines resource := resource // https://golang.org/doc/faq#closures_and_goroutines
g.Go(func() error { g.Go(func() error {
tmpfile, err := ioutil.TempFile("", "helm-manifest-*")
if err != nil {
return errors.Wrap(err, "failed to create a tmp helm manifest file")
}
defer func() {
tmpfile.Close()
os.Remove(tmpfile.Name())
}()
if _, err := tmpfile.Write(resource); err != nil {
return errors.Wrap(err, "failed to write a tmp helm manifest file")
}
// get resource namespace, fallback to provided namespace if not explicit on resource // get resource namespace, fallback to provided namespace if not explicit on resource
resourceNamespace, err := kubernetes.GetNamespace(resource) resourceNamespace, err := kubernetes.GetNamespace(resource)
if err != nil { if err != nil {
@ -201,7 +220,8 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte,
if resourceNamespace == "" { if resourceNamespace == "" {
resourceNamespace = namespace resourceNamespace = namespace
} }
_, err = handler.kubernetesDeployer.Deploy(r, endpoint, string(resource), resourceNamespace)
_, err = handler.kubernetesDeployer.Deploy(tokenData.ID, endpoint, []string{tmpfile.Name()}, resourceNamespace)
return err return err
}) })
} }

View File

@ -17,10 +17,8 @@ func startAutoupdate(stackID portainer.StackID, interval string, scheduler *sche
return "", &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Unable to parse stack's auto update interval", Err: err} return "", &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Unable to parse stack's auto update interval", Err: err}
} }
jobID = scheduler.StartJobEvery(d, func() { jobID = scheduler.StartJobEvery(d, func() error {
if err := stacks.RedeployWhenChanged(stackID, stackDeployer, datastore, gitService); err != nil { return stacks.RedeployWhenChanged(stackID, stackDeployer, datastore, gitService)
log.Printf("[ERROR] [http,stacks] [message: failed redeploying] [err: %s]\n", err)
}
}) })
return jobID, nil return jobID, nil

View File

@ -46,7 +46,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, false)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
} }
@ -152,7 +152,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
payload.ComposeFile = filesystem.ComposeFileDefaultName payload.ComposeFile = filesystem.ComposeFileDefaultName
} }
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, false)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
} }
@ -208,11 +208,11 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
} }
commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err}
} }
stack.GitConfig.ConfigHash = commitId stack.GitConfig.ConfigHash = commitID
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
if configErr != nil { if configErr != nil {
@ -281,7 +281,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, false)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
} }

View File

@ -2,9 +2,8 @@ package stacks
import ( import (
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"path/filepath" "os"
"strconv" "strconv"
"time" "time"
@ -19,16 +18,19 @@ import (
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/internal/stackutils"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
) )
type kubernetesStringDeploymentPayload struct { type kubernetesStringDeploymentPayload struct {
StackName string
ComposeFormat bool ComposeFormat bool
Namespace string Namespace string
StackFileContent string StackFileContent string
} }
type kubernetesGitDeploymentPayload struct { type kubernetesGitDeploymentPayload struct {
StackName string
ComposeFormat bool ComposeFormat bool
Namespace string Namespace string
RepositoryURL string RepositoryURL string
@ -36,10 +38,13 @@ type kubernetesGitDeploymentPayload struct {
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
FilePathInRepository string ManifestFile string
AdditionalFiles []string
AutoUpdate *portainer.StackAutoUpdate
} }
type kubernetesManifestURLDeploymentPayload struct { type kubernetesManifestURLDeploymentPayload struct {
StackName string
Namespace string Namespace string
ComposeFormat bool ComposeFormat bool
ManifestURL string ManifestURL string
@ -52,6 +57,9 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
if govalidator.IsNull(payload.Namespace) { if govalidator.IsNull(payload.Namespace) {
return errors.New("Invalid namespace") return errors.New("Invalid namespace")
} }
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil return nil
} }
@ -65,12 +73,18 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
} }
if govalidator.IsNull(payload.FilePathInRepository) { if govalidator.IsNull(payload.ManifestFile) {
return errors.New("Invalid file path in repository") return errors.New("Invalid manifest file in repository")
} }
if govalidator.IsNull(payload.RepositoryReferenceName) { if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName payload.RepositoryReferenceName = defaultGitReferenceName
} }
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil return nil
} }
@ -78,6 +92,9 @@ func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request)
if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) { if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) {
return errors.New("Invalid manifest URL") return errors.New("Invalid manifest URL")
} }
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil return nil
} }
@ -95,6 +112,13 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
} }
isUnique, err := handler.checkUniqueStackName(endpoint, payload.StackName, 0)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: errStackAlreadyExists}
}
stackID := handler.DataStore.Stack().GetNextIdentifier() stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{ stack := &portainer.Stack{
@ -102,6 +126,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
Type: portainer.KubernetesStack, Type: portainer.KubernetesStack,
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
EntryPoint: filesystem.ManifestFileDefaultName, EntryPoint: filesystem.ManifestFileDefaultName,
Name: payload.StackName,
Namespace: payload.Namespace, Namespace: payload.Namespace,
Status: portainer.StackStatusActive, Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
@ -124,9 +149,9 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
doCleanUp := true doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp) defer handler.cleanUp(stack, &doCleanUp)
output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{
StackID: stackID, StackID: stackID,
Name: stack.Name, StackName: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
Kind: "content", Kind: "content",
}) })
@ -140,12 +165,11 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
} }
doCleanUp = false
resp := &createKubernetesStackResponse{ resp := &createKubernetesStackResponse{
Output: output, Output: output,
} }
doCleanUp = false
return response.JSON(w, resp) return response.JSON(w, resp)
} }
@ -159,23 +183,44 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
} }
isUnique, err := handler.checkUniqueStackName(endpoint, payload.StackName, 0)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: errStackAlreadyExists}
}
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists}
}
}
stackID := handler.DataStore.Stack().GetNextIdentifier() stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{ stack := &portainer.Stack{
ID: portainer.StackID(stackID), ID: portainer.StackID(stackID),
Type: portainer.KubernetesStack, Type: portainer.KubernetesStack,
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
EntryPoint: payload.FilePathInRepository, EntryPoint: payload.ManifestFile,
GitConfig: &gittypes.RepoConfig{ GitConfig: &gittypes.RepoConfig{
URL: payload.RepositoryURL, URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName, ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository, ConfigFilePath: payload.ManifestFile,
}, },
Namespace: payload.Namespace, Namespace: payload.Namespace,
Name: payload.StackName,
Status: portainer.StackStatusActive, Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
CreatedBy: user.Username, CreatedBy: user.Username,
IsComposeFormat: payload.ComposeFormat, IsComposeFormat: payload.ComposeFormat,
AutoUpdate: payload.AutoUpdate,
AdditionalFiles: payload.AdditionalFiles,
} }
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {
@ -197,14 +242,21 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
} }
stack.GitConfig.ConfigHash = commitID stack.GitConfig.ConfigHash = commitID
stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath) repositoryUsername := payload.RepositoryUsername
if err != nil { repositoryPassword := payload.RepositoryPassword
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err} if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
} }
output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to clone git repository", Err: err}
}
output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{
StackID: stackID, StackID: stackID,
Name: stack.Name, StackName: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
Kind: "git", Kind: "git",
}) })
@ -213,17 +265,25 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
} }
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
if e != nil {
return e
}
stack.AutoUpdate.JobID = jobID
}
err = handler.DataStore.Stack().CreateStack(stack) err = handler.DataStore.Stack().CreateStack(stack)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
} }
doCleanUp = false
resp := &createKubernetesStackResponse{ resp := &createKubernetesStackResponse{
Output: output, Output: output,
} }
doCleanUp = false
return response.JSON(w, resp) return response.JSON(w, resp)
} }
@ -237,6 +297,13 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
} }
isUnique, err := handler.checkUniqueStackName(endpoint, payload.StackName, 0)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: errStackAlreadyExists}
}
stackID := handler.DataStore.Stack().GetNextIdentifier() stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{ stack := &portainer.Stack{
@ -245,6 +312,7 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
EntryPoint: filesystem.ManifestFileDefaultName, EntryPoint: filesystem.ManifestFileDefaultName,
Namespace: payload.Namespace, Namespace: payload.Namespace,
Name: payload.StackName,
Status: portainer.StackStatusActive, Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
CreatedBy: user.Username, CreatedBy: user.Username,
@ -267,9 +335,9 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
doCleanUp := true doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp) defer handler.cleanUp(stack, &doCleanUp)
output, err := handler.deployKubernetesStack(r, endpoint, string(manifestContent), payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{
StackID: stackID, StackID: stackID,
Name: stack.Name, StackName: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
Kind: "url", Kind: "url",
}) })
@ -291,42 +359,14 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
return response.JSON(w, resp) return response.JSON(w, resp)
} }
func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) { func (handler *Handler) deployKubernetesStack(userID portainer.UserID, endpoint *portainer.Endpoint, stack *portainer.Stack, appLabels k.KubeAppLabels) (string, error) {
handler.stackCreationMutex.Lock() handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock() defer handler.stackCreationMutex.Unlock()
manifest := []byte(stackConfig) manifestFilePaths, tempDir, err := stackutils.CreateTempK8SDeploymentFiles(stack, handler.KubernetesDeployer, appLabels)
if composeFormat {
convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(manifest)
if err != nil { if err != nil {
return "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest") return "", errors.Wrap(err, "failed to create temp kub deployment files")
} }
manifest = convertedConfig defer os.RemoveAll(tempDir)
} return handler.KubernetesDeployer.Deploy(userID, endpoint, manifestFilePaths, stack.Namespace)
manifest, err := k.AddAppLabels(manifest, appLabels.ToMap())
if err != nil {
return "", errors.Wrap(err, "failed to add application labels")
}
return handler.KubernetesDeployer.Deploy(request, endpoint, string(manifest), namespace)
}
func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {
repositoryUsername := gitInfo.RepositoryUsername
repositoryPassword := gitInfo.RepositoryPassword
if !gitInfo.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err := handler.GitService.CloneRepository(projectPath, gitInfo.RepositoryURL, gitInfo.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return "", err
}
content, err := ioutil.ReadFile(filepath.Join(projectPath, gitInfo.FilePathInRepository))
if err != nil {
return "", err
}
return string(content), nil
} }

View File

@ -1,68 +0,0 @@
package stacks
import (
"io/ioutil"
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
type git struct {
content string
}
func (g *git) CloneRepository(destination string, repositoryURL, referenceName, username, password string) error {
return g.ClonePublicRepository(repositoryURL, referenceName, destination)
}
func (g *git) ClonePublicRepository(repositoryURL string, referenceName string, destination string) error {
return ioutil.WriteFile(path.Join(destination, "deployment.yml"), []byte(g.content), 0755)
}
func (g *git) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
return g.ClonePublicRepository(repositoryURL, referenceName, destination)
}
func (g *git) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
return "", nil
}
func TestCloneAndConvertGitRepoFile(t *testing.T) {
dir, err := os.MkdirTemp("", "kube-create-stack")
assert.NoError(t, err, "failed to create a tmp dir")
defer os.RemoveAll(dir)
content := `apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80`
h := &Handler{
GitService: &git{
content: content,
},
}
gitInfo := &kubernetesGitDeploymentPayload{
FilePathInRepository: "deployment.yml",
}
fileContent, err := h.cloneManifestContentFromGitRepo(gitInfo, dir)
assert.NoError(t, err, "failed to clone or convert the file from Git repo")
assert.Equal(t, content, fileContent)
}

View File

@ -51,7 +51,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name) payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true) isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
} }
@ -161,7 +162,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name) payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true) isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
} }
@ -218,11 +219,11 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
} }
commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err}
} }
stack.GitConfig.ConfigHash = commitId stack.GitConfig.ConfigHash = commitID
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
if configErr != nil { if configErr != nil {
@ -298,7 +299,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name) payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true) isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
} }

View File

@ -127,7 +127,7 @@ func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedR
return handler.userIsAdminOrEndpointAdmin(user, endpointID) return handler.userIsAdminOrEndpointAdmin(user, endpointID)
} }
func (handler *Handler) checkUniqueName(endpoint *portainer.Endpoint, name string, stackID portainer.StackID, swarmMode bool) (bool, error) { func (handler *Handler) checkUniqueStackName(endpoint *portainer.Endpoint, name string, stackID portainer.StackID) (bool, error) {
stacks, err := handler.DataStore.Stack().Stacks() stacks, err := handler.DataStore.Stack().Stacks()
if err != nil { if err != nil {
return false, err return false, err
@ -139,6 +139,15 @@ func (handler *Handler) checkUniqueName(endpoint *portainer.Endpoint, name strin
} }
} }
return true, nil
}
func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoint, name string, stackID portainer.StackID, swarmMode bool) (bool, error) {
isUniqueStackName, err := handler.checkUniqueStackName(endpoint, name, stackID)
if err != nil {
return false, err
}
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
if err != nil { if err != nil {
return false, err return false, err
@ -171,7 +180,7 @@ func (handler *Handler) checkUniqueName(endpoint *portainer.Endpoint, name strin
} }
} }
return true, nil return isUniqueStackName, nil
} }
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) { func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {

View File

@ -2,15 +2,20 @@ package stacks
import ( import (
"context" "context"
"errors" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"path"
"strconv" "strconv"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors" bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/filesystem"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils" "github.com/portainer/portainer/api/internal/stackutils"
@ -34,12 +39,12 @@ import (
func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveRouteVariableValue(r, "id") stackID, err := request.RetrieveRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
} }
externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true) externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true)
@ -49,52 +54,52 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
id, err := strconv.Atoi(stackID) id, err := strconv.Atoi(stackID)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
} }
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(id)) stack, err := handler.DataStore.Stack().Stack(portainer.StackID(id))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} }
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
} }
isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID
if isOrphaned && !securityContext.IsAdmin { if isOrphaned && !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to remove orphaned stack", Err: errors.New("Permission denied to remove orphaned stack")}
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} }
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
} }
if !isOrphaned { if !isOrphaned {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
} }
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
} }
} }
} }
@ -104,26 +109,26 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
} }
err = handler.deleteStack(stack, endpoint) err = handler.deleteStack(securityContext.UserID, stack, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
err = handler.DataStore.Stack().DeleteStack(portainer.StackID(id)) err = handler.DataStore.Stack().DeleteStack(portainer.StackID(id))
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to remove the stack from the database", Err: err}
} }
if resourceControl != nil { if resourceControl != nil {
err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID) err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the associated resource control from the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to remove the associated resource control from the database", Err: err}
} }
} }
err = handler.FileService.RemoveDirectory(stack.ProjectPath) err = handler.FileService.RemoveDirectory(stack.ProjectPath)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove stack files from disk", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to remove stack files from disk", Err: err}
} }
return response.Empty(w) return response.Empty(w)
@ -132,31 +137,31 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string, securityContext *security.RestrictedRequestContext) *httperror.HandlerError { func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string, securityContext *security.RestrictedRequestContext) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
} }
if !securityContext.IsAdmin { if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusUnauthorized, "Permission denied to delete the stack", httperrors.ErrUnauthorized} return &httperror.HandlerError{StatusCode: http.StatusUnauthorized, Message: "Permission denied to delete the stack", Err: httperrors.ErrUnauthorized}
} }
stack, err := handler.DataStore.Stack().StackByName(stackName) stack, err := handler.DataStore.Stack().StackByName(stackName)
if err != nil && err != bolterrors.ErrObjectNotFound { if err != nil && err != bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for stack existence inside the database", Err: err}
} }
if stack != nil { if stack != nil {
return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", errors.New("A tag already exists with this name")} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "A stack with this name exists inside the database. Cannot use external delete method", Err: errors.New("A tag already exists with this name")}
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} }
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
} }
stack = &portainer.Stack{ stack = &portainer.Stack{
@ -164,18 +169,57 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit
Type: portainer.DockerSwarmStack, Type: portainer.DockerSwarmStack,
} }
err = handler.deleteStack(stack, endpoint) err = handler.deleteStack(securityContext.UserID, stack, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete stack", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to delete stack", Err: err}
} }
return response.Empty(w) return response.Empty(w)
} }
func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
if stack.Type == portainer.DockerSwarmStack { if stack.Type == portainer.DockerSwarmStack {
return handler.SwarmStackManager.Remove(stack, endpoint) return handler.SwarmStackManager.Remove(stack, endpoint)
} }
if stack.Type == portainer.DockerComposeStack {
return handler.ComposeStackManager.Down(context.TODO(), stack, endpoint) return handler.ComposeStackManager.Down(context.TODO(), stack, endpoint)
}
if stack.Type == portainer.KubernetesStack {
var manifestFiles []string
//if it is a compose format kub stack, create a temp dir and convert the manifest files into it
//then process the remove operation
if stack.IsComposeFormat {
fileNames := append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
tmpDir, err := ioutil.TempDir("", "kub_delete")
if err != nil {
return errors.Wrap(err, "failed to create temp directory for deleting kub stack")
}
defer os.RemoveAll(tmpDir)
for _, fileName := range fileNames {
manifestFilePath := path.Join(tmpDir, fileName)
manifestContent, err := ioutil.ReadFile(path.Join(stack.ProjectPath, fileName))
if err != nil {
return errors.Wrap(err, "failed to read manifest file")
}
manifestContent, err = handler.KubernetesDeployer.ConvertCompose(manifestContent)
if err != nil {
return errors.Wrap(err, "failed to convert docker compose file to a kube manifest")
}
err = filesystem.WriteToFile(manifestFilePath, []byte(manifestContent))
if err != nil {
return errors.Wrap(err, "failed to create temp manifest file")
}
manifestFiles = append(manifestFiles, manifestFilePath)
}
} else {
manifestFiles = stackutils.GetStackFilePaths(stack)
}
out, err := handler.KubernetesDeployer.Remove(userID, endpoint, manifestFiles, stack.Namespace)
return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out)
}
return fmt.Errorf("unsupported stack type: %v", stack.Type)
} }

View File

@ -50,52 +50,54 @@ func (payload *stackMigratePayload) Validate(r *http.Request) error {
func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
} }
var payload stackMigratePayload var payload stackMigratePayload
err = request.DecodeAndValidateJSONPayload(r, &payload) err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
}
if stack.Type == portainer.KubernetesStack {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Migrating a kubernetes stack is not supported", Err: err}
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} }
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
}
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
}
} }
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
@ -103,7 +105,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
// can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack.
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
} }
if endpointID != int(stack.EndpointID) { if endpointID != int(stack.EndpointID) {
stack.EndpointID = portainer.EndpointID(endpointID) stack.EndpointID = portainer.EndpointID(endpointID)
@ -111,9 +113,9 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
targetEndpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(payload.EndpointID)) targetEndpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(payload.EndpointID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} }
stack.EndpointID = portainer.EndpointID(payload.EndpointID) stack.EndpointID = portainer.EndpointID(payload.EndpointID)
@ -126,14 +128,14 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
stack.Name = payload.Name stack.Name = payload.Name
} }
isUnique, err := handler.checkUniqueName(targetEndpoint, stack.Name, stack.ID, stack.SwarmID != "") isUnique, err := handler.checkUniqueStackNameInDocker(targetEndpoint, stack.Name, stack.ID, stack.SwarmID != "")
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
} }
if !isUnique { if !isUnique {
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running on environment '%s'", stack.Name, targetEndpoint.Name) errorMessage := fmt.Sprintf("A stack with the name '%s' is already running on endpoint '%s'", stack.Name, targetEndpoint.Name)
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: errorMessage, Err: errors.New(errorMessage)}
} }
migrationError := handler.migrateStack(r, stack, targetEndpoint) migrationError := handler.migrateStack(r, stack, targetEndpoint)
@ -142,14 +144,14 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
} }
stack.Name = oldName stack.Name = oldName
err = handler.deleteStack(stack, endpoint) err = handler.deleteStack(securityContext.UserID, stack, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err}
} }
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
@ -175,7 +177,7 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
err := handler.deployComposeStack(config) err := handler.deployComposeStack(config)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
return nil return nil
@ -189,7 +191,7 @@ func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stac
err := handler.deploySwarmStack(config) err := handler.deploySwarmStack(config)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
return nil return nil

View File

@ -33,59 +33,61 @@ import (
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
} }
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
}
if stack.Type == portainer.KubernetesStack {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Starting a kubernetes stack is not supported", Err: err}
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} }
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
} }
isUnique, err := handler.checkUniqueName(endpoint, stack.Name, stack.ID, stack.SwarmID != "") isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, stack.Name, stack.ID, stack.SwarmID != "")
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
} }
if !isUnique { if !isUnique {
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", stack.Name) errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", stack.Name)
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: errorMessage, Err: errors.New(errorMessage)}
} }
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
}
} }
if stack.Status == portainer.StackStatusActive { if stack.Status == portainer.StackStatusActive {
return &httperror.HandlerError{http.StatusBadRequest, "Stack is already active", errors.New("Stack is already active")} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Stack is already active", Err: errors.New("Stack is already active")}
} }
if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" { if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" {
@ -101,13 +103,13 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
err = handler.startStack(stack, endpoint) err = handler.startStack(stack, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start stack", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to start stack", Err: err}
} }
stack.Status = portainer.StackStatusActive stack.Status = portainer.StackStatusActive
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update stack status", Err: err}
} }
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {

View File

@ -46,6 +46,10 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
} }
if stack.Type == portainer.KubernetesStack {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Stopping a kubernetes stack is not supported", Err: err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err} return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err}
@ -58,7 +62,6 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err}
} }
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
@ -71,7 +74,6 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
} }
}
if stack.Status == portainer.StackStatusInactive { if stack.Status == portainer.StackStatusInactive {
return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")} return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")}

View File

@ -1,10 +1,11 @@
package stacks package stacks
import ( import (
"errors"
"net/http" "net/http"
"time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
@ -98,17 +99,22 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access environment", Err: err} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access environment", Err: err}
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Cannot find context user", Err: errors.Wrap(err, "failed to fetch the user")}
}
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
@ -127,6 +133,8 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.AutoUpdate = payload.AutoUpdate stack.AutoUpdate = payload.AutoUpdate
stack.Env = payload.Env stack.Env = payload.Env
stack.UpdatedBy = user.Username
stack.UpdateDate = time.Now().Unix()
stack.GitConfig.Authentication = nil stack.GitConfig.Authentication = nil
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {

View File

@ -2,10 +2,8 @@ package stacks
import ( import (
"fmt" "fmt"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"path/filepath"
"time" "time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
@ -216,14 +214,14 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, end
if stack.Namespace == "" { if stack.Namespace == "" {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Invalid namespace", Err: errors.New("Namespace must not be empty when redeploying kubernetes stacks")} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Invalid namespace", Err: errors.New("Namespace must not be empty when redeploying kubernetes stacks")}
} }
content, err := ioutil.ReadFile(filepath.Join(stack.ProjectPath, stack.GitConfig.ConfigFilePath)) tokenData, err := security.RetrieveTokenData(r)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to read deployment.yml manifest file", Err: errors.Wrap(err, "failed to read manifest file")} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Failed to retrieve user token data", Err: err}
} }
_, err = handler.deployKubernetesStack(r, endpoint, string(content), stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{ _, err = handler.deployKubernetesStack(tokenData.ID, endpoint, stack, k.KubeAppLabels{
StackID: int(stack.ID), StackID: int(stack.ID),
Name: stack.Name, StackName: stack.Name,
Owner: stack.CreatedBy, Owner: tokenData.Username,
Kind: "git", Kind: "git",
}) })
if err != nil { if err != nil {

View File

@ -2,7 +2,10 @@ package stacks
import ( import (
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"path"
"strconv" "strconv"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
@ -10,7 +13,9 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
) )
@ -23,6 +28,7 @@ type kubernetesGitStackUpdatePayload struct {
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
AutoUpdate *portainer.StackAutoUpdate
} }
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
@ -36,12 +42,20 @@ func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error
if govalidator.IsNull(payload.RepositoryReferenceName) { if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName payload.RepositoryReferenceName = defaultGitReferenceName
} }
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}
return nil return nil
} }
func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
if stack.GitConfig != nil { if stack.GitConfig != nil {
//stop the autoupdate job if there is any
if stack.AutoUpdate != nil {
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
}
var payload kubernetesGitStackUpdatePayload var payload kubernetesGitStackUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
@ -49,6 +63,8 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
} }
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.AutoUpdate = payload.AutoUpdate
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {
password := payload.RepositoryPassword password := payload.RepositoryPassword
if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
@ -61,6 +77,15 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
} else { } else {
stack.GitConfig.Authentication = nil stack.GitConfig.Authentication = nil
} }
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
if e != nil {
return e
}
stack.AutoUpdate.JobID = jobID
}
return nil return nil
} }
@ -71,9 +96,25 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
_, err = handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{ tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Failed to retrieve user token data", Err: err}
}
tempFileDir, _ := ioutil.TempDir("", "kub_file_content")
defer os.RemoveAll(tempFileDir)
if err := filesystem.WriteToFile(path.Join(tempFileDir, stack.EntryPoint), []byte(payload.StackFileContent)); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to persist deployment file in a temp directory", Err: err}
}
//use temp dir as the stack project path for deployment
//so if the deployment failed, the original file won't be over-written
stack.ProjectPath = tempFileDir
_, err = handler.deployKubernetesStack(tokenData.ID, endpoint, stack, k.KubeAppLabels{
StackID: int(stack.ID), StackID: int(stack.ID),
Name: stack.Name, StackName: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
Kind: "content", Kind: "content",
}) })
@ -83,7 +124,7 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
} }
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
fileType := "Manifest" fileType := "Manifest"
if stack.IsComposeFormat { if stack.IsComposeFormat {
@ -92,6 +133,7 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType) errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType)
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err}
} }
stack.ProjectPath = projectPath
return nil return nil
} }

View File

@ -1,10 +1,10 @@
package stacks package stacks
import ( import (
"log"
"net/http" "net/http"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/sirupsen/logrus"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
@ -31,7 +31,10 @@ func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *h
} }
if err = stacks.RedeployWhenChanged(stack.ID, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil { if err = stacks.RedeployWhenChanged(stack.ID, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil {
log.Printf("[ERROR] %s\n", err) if _, ok := err.(*stacks.StackAuthorMissingErr); ok {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: "Autoupdate for the stack isn't available", Err: err}
}
logrus.WithError(err).Error("failed to update the stack")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to update the stack", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to update the stack", Err: err}
} }

View File

@ -24,6 +24,7 @@ type ProxyServer struct {
// NewAgentProxy creates a new instance of ProxyServer that wrap http requests with agent headers // NewAgentProxy creates a new instance of ProxyServer that wrap http requests with agent headers
func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) { func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) {
urlString := endpoint.URL urlString := endpoint.URL
if endpointutils.IsEdgeEndpoint((endpoint)) { if endpointutils.IsEdgeEndpoint((endpoint)) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint) tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil { if err != nil {

View File

@ -11,7 +11,7 @@ func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5 return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5
} }
// IsKubernetesEndpoint returns true if this is a kubernetes environment(endpoint) // IsKubernetesEndpoint returns true if this is a kubernetes endpoint
func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool { func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.KubernetesLocalEnvironment || return endpoint.Type == portainer.KubernetesLocalEnvironment ||
endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
@ -25,6 +25,7 @@ func IsDockerEndpoint(endpoint *portainer.Endpoint) bool {
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment endpoint.Type == portainer.EdgeAgentOnDockerEnvironment
} }
// IsEdgeEndpoint returns true if this is an Edge endpoint
func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool { func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
} }

View File

@ -2,9 +2,13 @@ package stackutils
import ( import (
"fmt" "fmt"
"io/ioutil"
"path" "path"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
k "github.com/portainer/portainer/api/kubernetes"
) )
// ResourceControlID returns the stack resource control id // ResourceControlID returns the stack resource control id
@ -20,3 +24,39 @@ func GetStackFilePaths(stack *portainer.Stack) []string {
} }
return filePaths return filePaths
} }
// CreateTempK8SDeploymentFiles reads manifest files from original stack project path
// then add app labels into the file contents and create temp files for deployment
// return temp file paths and temp dir
func CreateTempK8SDeploymentFiles(stack *portainer.Stack, kubeDeployer portainer.KubernetesDeployer, appLabels k.KubeAppLabels) ([]string, string, error) {
fileNames := append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
var manifestFilePaths []string
tmpDir, err := ioutil.TempDir("", "kub_deployment")
if err != nil {
return nil, "", errors.Wrap(err, "failed to create temp kub deployment directory")
}
for _, fileName := range fileNames {
manifestFilePath := path.Join(tmpDir, fileName)
manifestContent, err := ioutil.ReadFile(path.Join(stack.ProjectPath, fileName))
if err != nil {
return nil, "", errors.Wrap(err, "failed to read manifest file")
}
if stack.IsComposeFormat {
manifestContent, err = kubeDeployer.ConvertCompose(manifestContent)
if err != nil {
return nil, "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest")
}
}
manifestContent, err = k.AddAppLabels(manifestContent, appLabels.ToMap())
if err != nil {
return nil, "", errors.Wrap(err, "failed to add application labels")
}
err = filesystem.WriteToFile(manifestFilePath, []byte(manifestContent))
if err != nil {
return nil, "", errors.Wrap(err, "failed to create temp manifest file")
}
manifestFilePaths = append(manifestFilePaths, manifestFilePath)
}
return manifestFilePaths, tmpDir, nil
}

View File

@ -12,6 +12,7 @@ import (
) )
const ( const (
labelPortainerAppStack = "io.portainer.kubernetes.application.stack"
labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid" labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid"
labelPortainerAppName = "io.portainer.kubernetes.application.name" labelPortainerAppName = "io.portainer.kubernetes.application.name"
labelPortainerAppOwner = "io.portainer.kubernetes.application.owner" labelPortainerAppOwner = "io.portainer.kubernetes.application.owner"
@ -21,7 +22,7 @@ const (
// KubeAppLabels are labels applied to all resources deployed in a kubernetes stack // KubeAppLabels are labels applied to all resources deployed in a kubernetes stack
type KubeAppLabels struct { type KubeAppLabels struct {
StackID int StackID int
Name string StackName string
Owner string Owner string
Kind string Kind string
} }
@ -30,7 +31,8 @@ type KubeAppLabels struct {
func (kal *KubeAppLabels) ToMap() map[string]string { func (kal *KubeAppLabels) ToMap() map[string]string {
return map[string]string{ return map[string]string{
labelPortainerAppStackID: strconv.Itoa(kal.StackID), labelPortainerAppStackID: strconv.Itoa(kal.StackID),
labelPortainerAppName: kal.Name, labelPortainerAppStack: kal.StackName,
labelPortainerAppName: kal.StackName,
labelPortainerAppOwner: kal.Owner, labelPortainerAppOwner: kal.Owner,
labelPortainerAppKind: kal.Kind, labelPortainerAppKind: kal.Kind,
} }
@ -167,13 +169,6 @@ func addLabels(obj map[string]interface{}, appLabels map[string]string) {
labels[k] = v labels[k] = v
} }
// fallback to metadata name if name label not explicitly provided
if name, ok := labels[labelPortainerAppName]; !ok || name == "" {
if n, ok := metadata["name"]; ok {
labels[labelPortainerAppName] = n.(string)
}
}
metadata["labels"] = labels metadata["labels"] = labels
obj["metadata"] = metadata obj["metadata"] = metadata
} }

View File

@ -39,6 +39,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: busybox name: busybox
spec: spec:
@ -86,6 +87,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: busybox name: busybox
spec: spec:
@ -174,6 +176,7 @@ items:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: web name: web
spec: spec:
@ -194,6 +197,7 @@ items:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: redis name: redis
spec: spec:
@ -216,6 +220,7 @@ items:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: web name: web
spec: spec:
@ -297,6 +302,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: busybox name: busybox
spec: spec:
@ -322,6 +328,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: web name: web
spec: spec:
@ -340,6 +347,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: busybox name: busybox
spec: spec:
@ -388,6 +396,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
name: web name: web
spec: spec:
@ -403,7 +412,7 @@ spec:
labels := KubeAppLabels{ labels := KubeAppLabels{
StackID: 123, StackID: 123,
Name: "best-name", StackName: "best-name",
Owner: "best-owner", Owner: "best-owner",
Kind: "git", Kind: "git",
} }
@ -417,81 +426,6 @@ spec:
} }
} }
func Test_AddAppLabels_PickingName_WhenLabelNameIsEmpty(t *testing.T) {
labels := KubeAppLabels{
StackID: 123,
Owner: "best-owner",
Kind: "git",
}
input := `apiVersion: v1
kind: Service
metadata:
name: web
spec:
ports:
- name: "5000"
port: 5000
targetPort: 5000
`
expected := `apiVersion: v1
kind: Service
metadata:
labels:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: web
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stackid: "123"
name: web
spec:
ports:
- name: "5000"
port: 5000
targetPort: 5000
`
result, err := AddAppLabels([]byte(input), labels.ToMap())
assert.NoError(t, err)
assert.Equal(t, expected, string(result))
}
func Test_AddAppLabels_PickingName_WhenLabelAndMetadataNameAreEmpty(t *testing.T) {
labels := KubeAppLabels{
StackID: 123,
Owner: "best-owner",
Kind: "git",
}
input := `apiVersion: v1
kind: Service
spec:
ports:
- name: "5000"
port: 5000
targetPort: 5000
`
expected := `apiVersion: v1
kind: Service
metadata:
labels:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: ""
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stackid: "123"
spec:
ports:
- name: "5000"
port: 5000
targetPort: 5000
`
result, err := AddAppLabels([]byte(input), labels.ToMap())
assert.NoError(t, err)
assert.Equal(t, expected, string(result))
}
func Test_AddAppLabels_HelmApp(t *testing.T) { func Test_AddAppLabels_HelmApp(t *testing.T) {
labels := GetHelmAppLabels("best-name", "best-owner") labels := GetHelmAppLabels("best-name", "best-owner")
@ -659,7 +593,7 @@ spec:
func Test_DocumentSeperator(t *testing.T) { func Test_DocumentSeperator(t *testing.T) {
labels := KubeAppLabels{ labels := KubeAppLabels{
StackID: 123, StackID: 123,
Name: "best-name", StackName: "best-name",
Owner: "best-owner", Owner: "best-owner",
Kind: "git", Kind: "git",
} }
@ -684,6 +618,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
--- ---
apiVersion: v1 apiVersion: v1
@ -694,6 +629,7 @@ metadata:
io.portainer.kubernetes.application.kind: git io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123" io.portainer.kubernetes.application.stackid: "123"
` `
result, err := AddAppLabels([]byte(input), labels.ToMap()) result, err := AddAppLabels([]byte(input), labels.ToMap())

View File

@ -3,7 +3,6 @@ package portainer
import ( import (
"context" "context"
"io" "io"
"net/http"
"time" "time"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
@ -1281,7 +1280,8 @@ type (
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint) // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)
KubernetesDeployer interface { KubernetesDeployer interface {
Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error) Deploy(userID UserID, endpoint *Endpoint, manifestFiles []string, namespace string) (string, error)
Remove(userID UserID, endpoint *Endpoint, manifestFiles []string, namespace string) (string, error)
ConvertCompose(data []byte) ([]byte, error) ConvertCompose(data []byte) ([]byte, error)
} }

View File

@ -8,11 +8,12 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"github.com/sirupsen/logrus"
) )
type Scheduler struct { type Scheduler struct {
crontab *cron.Cron crontab *cron.Cron
shutdownCtx context.Context activeJobs map[cron.EntryID]context.CancelFunc
} }
func NewScheduler(ctx context.Context) *Scheduler { func NewScheduler(ctx context.Context) *Scheduler {
@ -21,6 +22,7 @@ func NewScheduler(ctx context.Context) *Scheduler {
s := &Scheduler{ s := &Scheduler{
crontab: crontab, crontab: crontab,
activeJobs: make(map[cron.EntryID]context.CancelFunc),
} }
if ctx != nil { if ctx != nil {
@ -43,8 +45,10 @@ func (s *Scheduler) Shutdown() error {
ctx := s.crontab.Stop() ctx := s.crontab.Stop()
<-ctx.Done() <-ctx.Done()
for _, j := range s.crontab.Entries() { for _, job := range s.crontab.Entries() {
s.crontab.Remove(j.ID) if cancel, ok := s.activeJobs[job.ID]; ok {
cancel()
}
} }
err := ctx.Err() err := ctx.Err()
@ -60,14 +64,36 @@ func (s *Scheduler) StopJob(jobID string) error {
if err != nil { if err != nil {
return errors.Wrapf(err, "failed convert jobID %q to int", jobID) return errors.Wrapf(err, "failed convert jobID %q to int", jobID)
} }
s.crontab.Remove(cron.EntryID(id)) entryID := cron.EntryID(id)
if cancel, ok := s.activeJobs[entryID]; ok {
cancel()
}
return nil return nil
} }
// StartJobEvery schedules a new periodic job with a given duration. // StartJobEvery schedules a new periodic job with a given duration.
// Returns job id that could be used to stop the given job // Returns job id that could be used to stop the given job.
func (s *Scheduler) StartJobEvery(duration time.Duration, job func()) string { // When job run returns an error, that job won't be run again.
entryId := s.crontab.Schedule(cron.Every(duration), cron.FuncJob(job)) func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
return strconv.Itoa(int(entryId)) ctx, cancel := context.WithCancel(context.Background())
j := cron.FuncJob(func() {
if err := job(); err != nil {
logrus.Debug("job returned an error")
cancel()
}
})
entryID := s.crontab.Schedule(cron.Every(duration), j)
s.activeJobs[entryID] = cancel
go func(entryID cron.EntryID) {
<-ctx.Done()
logrus.Debug("job cancelled, stopping")
s.crontab.Remove(entryID)
}(entryID)
return strconv.Itoa(int(entryID))
} }

View File

@ -9,49 +9,92 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func Test_CanStartAndTerminate(t *testing.T) { var jobInterval = time.Second
s := NewScheduler(context.Background())
s.StartJobEvery(1*time.Minute, func() { fmt.Println("boop") })
err := s.Shutdown() func Test_ScheduledJobRuns(t *testing.T) {
assert.NoError(t, err, "Shutdown should return no errors")
assert.Empty(t, s.crontab.Entries(), "all jobs should have been removed")
}
func Test_CanTerminateByCancellingContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
s := NewScheduler(ctx)
s.StartJobEvery(1*time.Minute, func() { fmt.Println("boop") })
cancel()
for i := 0; i < 100; i++ {
if len(s.crontab.Entries()) == 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("all jobs are expected to be cleaned by now; it might be a timing issue, otherwise implementation defect")
}
func Test_StartAndStopJob(t *testing.T) {
s := NewScheduler(context.Background()) s := NewScheduler(context.Background())
defer s.Shutdown() defer s.Shutdown()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
var jobOne string
var workDone bool var workDone bool
jobOne = s.StartJobEvery(time.Second, func() { s.StartJobEvery(jobInterval, func() error {
assert.Equal(t, 1, len(s.crontab.Entries()), "scheduler should have one active job")
workDone = true workDone = true
s.StopJob(jobOne)
cancel() cancel()
return nil
}) })
<-ctx.Done() <-ctx.Done()
assert.True(t, workDone, "value should been set in the job") assert.True(t, workDone, "value should been set in the job")
assert.Equal(t, 0, len(s.crontab.Entries()), "scheduler should have no active jobs") }
func Test_JobCanBeStopped(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
var workDone bool
jobID := s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
return nil
})
s.StopJob(jobID)
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_JobShouldStop_UponError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
var acc int
s.StartJobEvery(jobInterval, func() error {
acc++
return fmt.Errorf("failed")
})
<-time.After(3 * jobInterval)
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
}
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
s := NewScheduler(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
return nil
})
s.Shutdown()
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_CanTerminateAllJobs_ByCancellingParentContext(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
s := NewScheduler(ctx)
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
return nil
})
cancel()
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
} }

View File

@ -1,15 +1,29 @@
package stacks package stacks
import ( import (
"fmt"
"strings" "strings"
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
log "github.com/sirupsen/logrus"
) )
type StackAuthorMissingErr struct {
stackID int
authorName string
}
func (e *StackAuthorMissingErr) Error() string {
return fmt.Sprintf("stack's %v author %s is missing", e.stackID, e.authorName)
}
func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) error { func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) error {
logger := log.WithFields(log.Fields{"stackID": stackID})
logger.Debug("redeploying stack")
stack, err := datastore.Stack().Stack(stackID) stack, err := datastore.Stack().Stack(stackID)
if err != nil { if err != nil {
return errors.WithMessagef(err, "failed to get the stack %v", stackID) return errors.WithMessagef(err, "failed to get the stack %v", stackID)
@ -19,6 +33,17 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return nil // do nothing if it isn't a git-based stack return nil // do nothing if it isn't a git-based stack
} }
author := stack.UpdatedBy
if author == "" {
author = stack.CreatedBy
}
user, err := datastore.User().UserByUsername(author)
if err != nil {
logger.WithFields(log.Fields{"author": author, "stack": stack.Name, "endpointID": stack.EndpointID}).Warn("cannot autoupdate a stack, stack author user is missing")
return &StackAuthorMissingErr{int(stack.ID), author}
}
username, password := "", "" username, password := "", ""
if stack.GitConfig.Authentication != nil { if stack.GitConfig.Authentication != nil {
username, password = stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password username, password = stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password
@ -54,12 +79,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID) return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
} }
author := stack.UpdatedBy registries, err := getUserRegistries(datastore, user, endpoint.ID)
if author == "" {
author = stack.CreatedBy
}
registries, err := getUserRegistries(datastore, author, endpoint.ID)
if err != nil { if err != nil {
return err return err
} }
@ -75,6 +95,12 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
if err != nil { if err != nil {
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
} }
case portainer.KubernetesStack:
logger.Debugf("deploying a kube app")
err := deployer.DeployKubernetesStack(stack, endpoint, user)
if err != nil {
return errors.WithMessagef(err, "failed to deploy a kubternetes app stack %v", stackID)
}
default: default:
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type) return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
} }
@ -88,24 +114,19 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return nil return nil
} }
func getUserRegistries(datastore portainer.DataStore, authorUsername string, endpointID portainer.EndpointID) ([]portainer.Registry, error) { func getUserRegistries(datastore portainer.DataStore, user *portainer.User, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
registries, err := datastore.Registry().Registries() registries, err := datastore.Registry().Registries()
if err != nil { if err != nil {
return nil, errors.WithMessage(err, "unable to retrieve registries from the database") return nil, errors.WithMessage(err, "unable to retrieve registries from the database")
} }
user, err := datastore.User().UserByUsername(authorUsername)
if err != nil {
return nil, errors.WithMessagef(err, "failed to fetch a stack's author [%s]", authorUsername)
}
if user.Role == portainer.AdministratorRole { if user.Role == portainer.AdministratorRole {
return registries, nil return registries, nil
} }
userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID) userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil { if err != nil {
return nil, errors.WithMessagef(err, "failed to fetch memberships of the stack author [%s]", authorUsername) return nil, errors.WithMessagef(err, "failed to fetch memberships of the stack author [%s]", user.Username)
} }
filteredRegistries := make([]portainer.Registry, 0, len(registries)) filteredRegistries := make([]portainer.Registry, 0, len(registries))

View File

@ -35,6 +35,10 @@ func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *port
return nil return nil
} }
func (s *noopDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
return nil
}
func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) { func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) {
store, teardown := bolt.MustNewTestStore(true) store, teardown := bolt.MustNewTestStore(true)
defer teardown() defer teardown()
@ -48,7 +52,11 @@ func Test_redeployWhenChanged_DoesNothingWhenNotAGitBasedStack(t *testing.T) {
store, teardown := bolt.MustNewTestStore(true) store, teardown := bolt.MustNewTestStore(true)
defer teardown() defer teardown()
err := store.Stack().CreateStack(&portainer.Stack{ID: 1}) admin := &portainer.User{ID: 1, Username: "admin"}
err := store.User().CreateUser(admin)
assert.NoError(t, err, "error creating an admin")
err = store.Stack().CreateStack(&portainer.Stack{ID: 1, CreatedBy: "admin"})
assert.NoError(t, err, "failed to create a test stack") assert.NoError(t, err, "failed to create a test stack")
err = RedeployWhenChanged(1, nil, store, &gitService{nil, ""}) err = RedeployWhenChanged(1, nil, store, &gitService{nil, ""})
@ -61,8 +69,13 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
tmpDir, _ := ioutil.TempDir("", "stack") tmpDir, _ := ioutil.TempDir("", "stack")
err := store.Stack().CreateStack(&portainer.Stack{ admin := &portainer.User{ID: 1, Username: "admin"}
err := store.User().CreateUser(admin)
assert.NoError(t, err, "error creating an admin")
err = store.Stack().CreateStack(&portainer.Stack{
ID: 1, ID: 1,
CreatedBy: "admin",
ProjectPath: tmpDir, ProjectPath: tmpDir,
GitConfig: &gittypes.RepoConfig{ GitConfig: &gittypes.RepoConfig{
URL: "url", URL: "url",
@ -80,8 +93,13 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
store, teardown := bolt.MustNewTestStore(true) store, teardown := bolt.MustNewTestStore(true)
defer teardown() defer teardown()
err := store.Stack().CreateStack(&portainer.Stack{ admin := &portainer.User{ID: 1, Username: "admin"}
err := store.User().CreateUser(admin)
assert.NoError(t, err, "error creating an admin")
err = store.Stack().CreateStack(&portainer.Stack{
ID: 1, ID: 1,
CreatedBy: "admin",
GitConfig: &gittypes.RepoConfig{ GitConfig: &gittypes.RepoConfig{
URL: "url", URL: "url",
ReferenceName: "ref", ReferenceName: "ref",
@ -136,12 +154,12 @@ func Test_redeployWhenChanged(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
}) })
t.Run("can NOT deploy kube stack", func(t *testing.T) { t.Run("can deploy kube app", func(t *testing.T) {
stack.Type = portainer.KubernetesStack stack.Type = portainer.KubernetesStack
store.Stack().UpdateStack(stack.ID, &stack) store.Stack().UpdateStack(stack.ID, &stack)
err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"}) err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"})
assert.EqualError(t, err, "cannot update stack, type 3 is unsupported") assert.NoError(t, err)
}) })
} }
@ -151,12 +169,12 @@ func Test_getUserRegistries(t *testing.T) {
endpointID := 123 endpointID := 123
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} admin := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
err := store.User().CreateUser(&admin) err := store.User().CreateUser(admin)
assert.NoError(t, err, "error creating an admin") assert.NoError(t, err, "error creating an admin")
user := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole} user := &portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
err = store.User().CreateUser(&user) err = store.User().CreateUser(user)
assert.NoError(t, err, "error creating a user") assert.NoError(t, err, "error creating a user")
team := portainer.Team{ID: 1, Name: "team"} team := portainer.Team{ID: 1, Name: "team"}
@ -208,13 +226,13 @@ func Test_getUserRegistries(t *testing.T) {
assert.NoError(t, err, "couldn't create a registry") assert.NoError(t, err, "couldn't create a registry")
t.Run("admin should has access to all registries", func(t *testing.T) { t.Run("admin should has access to all registries", func(t *testing.T) {
registries, err := getUserRegistries(store, admin.Username, portainer.EndpointID(endpointID)) registries, err := getUserRegistries(store, admin, portainer.EndpointID(endpointID))
assert.NoError(t, err) assert.NoError(t, err)
assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam, registryRestricted}, registries) assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam, registryRestricted}, registries)
}) })
t.Run("regular user has access to registries allowed to him and/or his team", func(t *testing.T) { t.Run("regular user has access to registries allowed to him and/or his team", func(t *testing.T) {
registries, err := getUserRegistries(store, user.Username, portainer.EndpointID(endpointID)) registries, err := getUserRegistries(store, user, portainer.EndpointID(endpointID))
assert.NoError(t, err) assert.NoError(t, err)
assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam}, registries) assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam}, registries)
}) })

View File

@ -2,27 +2,36 @@ package stacks
import ( import (
"context" "context"
"os"
"sync" "sync"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/stackutils"
k "github.com/portainer/portainer/api/kubernetes"
) )
type StackDeployer interface { type StackDeployer interface {
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error
} }
type stackDeployer struct { type stackDeployer struct {
lock *sync.Mutex lock *sync.Mutex
swarmStackManager portainer.SwarmStackManager swarmStackManager portainer.SwarmStackManager
composeStackManager portainer.ComposeStackManager composeStackManager portainer.ComposeStackManager
kubernetesDeployer portainer.KubernetesDeployer
} }
func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager) *stackDeployer { // NewStackDeployer inits a stackDeployer struct with a SwarmStackManager, a ComposeStackManager and a KubernetesDeployer
func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager, kubernetesDeployer portainer.KubernetesDeployer) *stackDeployer {
return &stackDeployer{ return &stackDeployer{
lock: &sync.Mutex{}, lock: &sync.Mutex{},
swarmStackManager: swarmStackManager, swarmStackManager: swarmStackManager,
composeStackManager: composeStackManager, composeStackManager: composeStackManager,
kubernetesDeployer: kubernetesDeployer,
} }
} }
@ -45,3 +54,33 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
return d.composeStackManager.Up(context.TODO(), stack, endpoint) return d.composeStackManager.Up(context.TODO(), stack, endpoint)
} }
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
d.lock.Lock()
defer d.lock.Unlock()
appLabels := k.KubeAppLabels{
StackID: int(stack.ID),
StackName: stack.Name,
Owner: user.Username,
}
if stack.GitConfig == nil {
appLabels.Kind = "content"
} else {
appLabels.Kind = "git"
}
manifestFilePaths, tempDir, err := stackutils.CreateTempK8SDeploymentFiles(stack, d.kubernetesDeployer, appLabels)
if err != nil {
return errors.Wrap(err, "failed to create temp kub deployment files")
}
defer os.RemoveAll(tempDir)
_, err = d.kubernetesDeployer.Deploy(user.ID, endpoint, manifestFilePaths, stack.Namespace)
if err != nil {
return errors.Wrap(err, "failed to deploy kubernetes application")
}
return nil
}

View File

@ -1,7 +1,6 @@
package stacks package stacks
import ( import (
"log"
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -19,10 +18,9 @@ func StartStackSchedules(scheduler *scheduler.Scheduler, stackdeployer StackDepl
if err != nil { if err != nil {
return errors.Wrap(err, "Unable to parse auto update interval") return errors.Wrap(err, "Unable to parse auto update interval")
} }
jobID := scheduler.StartJobEvery(d, func() { stackID := stack.ID // to be captured by the scheduled function
if err := RedeployWhenChanged(stack.ID, stackdeployer, datastore, gitService); err != nil { jobID := scheduler.StartJobEvery(d, func() error {
log.Printf("[ERROR] %s\n", err) return RedeployWhenChanged(stackID, stackdeployer, datastore, gitService)
}
}) })
stack.AutoUpdate.JobID = jobID stack.AutoUpdate.JobID = jobID

View File

@ -56,7 +56,7 @@ angular.module('portainer').config([
closeButton: true, closeButton: true,
progressBar: true, progressBar: true,
tapToDismiss: false, tapToDismiss: false,
} };
Terminal.applyAddon(fit); Terminal.applyAddon(fit);

View File

@ -65,7 +65,9 @@
</div> </div>
</div> </div>
<div class="form-group" ng-if="$ctrl.noEndpoints"> <div class="form-group" ng-if="$ctrl.noEndpoints">
<div class="col-sm-12 small text-muted"> No Edge environments are available. Head over to the <a ui-sref="portainer.endpoints">Environments view</a> to add environments. </div> <div class="col-sm-12 small text-muted">
No Edge environments are available. Head over to the <a ui-sref="portainer.endpoints">Environments view</a> to add environments.
</div>
</div> </div>
</div> </div>
<!-- !StaticGroup --> <!-- !StaticGroup -->

View File

@ -1,4 +1,10 @@
<span class="interactive" tooltip-append-to-body="true" tooltip-placement="bottom" tooltip-class="portainer-tooltip" uib-tooltip="Kubeconfig file will {{ $ctrl.state.expiryDays }}"> <span
class="interactive"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Kubeconfig file will {{ $ctrl.state.expiryDays }}"
>
<button <button
ng-if="$ctrl.state.isHTTPS" ng-if="$ctrl.state.isHTTPS"
type="button" type="button"
@ -7,7 +13,7 @@
analytics-on analytics-on
analytics-category="kubernetes" analytics-category="kubernetes"
analytics-event="kubernetes-kubectl-kubeconfig" analytics-event="kubernetes-kubectl-kubeconfig"
> >
Kubeconfig <i class="fas fa-download space-right"></i> Kubeconfig <i class="fas fa-download space-right"></i>
</button> </button>
</span> </span>

View File

@ -15,3 +15,8 @@ export const KubernetesDeployRequestMethods = Object.freeze({
STRING: 'string', STRING: 'string',
URL: 'url', URL: 'url',
}); });
export const RepositoryMechanismTypes = Object.freeze({
WEBHOOK: 'Webhook',
INTERVAL: 'Interval',
});

View File

@ -102,7 +102,8 @@ class KubernetesServiceService {
const namespace = service.Namespace; const namespace = service.Namespace;
await this.KubernetesServices(namespace).delete(params).$promise; await this.KubernetesServices(namespace).delete(params).$promise;
} catch (err) { } catch (err) {
throw new PortainerError('Unable to remove service', err); // eslint-disable-next-line no-console
console.error('unable to remove service', err);
} }
} }

View File

@ -7,7 +7,7 @@ import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models
class KubernetesApplicationsController { class KubernetesApplicationsController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, Notifications, KubernetesApplicationService, HelmService, KubernetesConfigurationService, Authentication, ModalService, LocalStorage) { constructor($async, $state, Notifications, KubernetesApplicationService, HelmService, KubernetesConfigurationService, Authentication, ModalService, LocalStorage, StackService) {
this.$async = $async; this.$async = $async;
this.$state = $state; this.$state = $state;
this.Notifications = Notifications; this.Notifications = Notifications;
@ -17,6 +17,7 @@ class KubernetesApplicationsController {
this.Authentication = Authentication; this.Authentication = Authentication;
this.ModalService = ModalService; this.ModalService = ModalService;
this.LocalStorage = LocalStorage; this.LocalStorage = LocalStorage;
this.StackService = StackService;
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.getApplications = this.getApplications.bind(this); this.getApplications = this.getApplications.bind(this);
@ -36,8 +37,18 @@ class KubernetesApplicationsController {
let actionCount = selectedItems.length; let actionCount = selectedItems.length;
for (const stack of selectedItems) { for (const stack of selectedItems) {
try { try {
const isAppFormCreated = stack.Applications.some((x) => !x.ApplicationKind);
if (isAppFormCreated) {
const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app)); const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app));
await Promise.all(promises); await Promise.all(promises);
} else {
const application = stack.Applications.find((x) => x.StackId !== null);
if (application && application.StackId) {
await this.StackService.remove({ Id: application.StackId }, false, this.endpoint.Id);
}
}
this.Notifications.success('Stack successfully removed', stack.Name); this.Notifications.success('Stack successfully removed', stack.Name);
_.remove(this.state.stacks, { Name: stack.Name }); _.remove(this.state.stacks, { Name: stack.Name });
} catch (err) { } catch (err) {
@ -70,6 +81,14 @@ class KubernetesApplicationsController {
await this.HelmService.uninstall(this.endpoint.Id, application); await this.HelmService.uninstall(this.endpoint.Id, application);
} else { } else {
await this.KubernetesApplicationService.delete(application); await this.KubernetesApplicationService.delete(application);
// Update applications in stack
const stack = this.state.stacks.find((x) => x.Name === application.StackName);
const index = stack.Applications.indexOf(application);
stack.Applications.splice(index, 1);
// remove stack if no app left in the stack
if (stack.Applications.length === 0 && application.StackId) {
await this.StackService.remove({ Id: application.StackId }, false, this.endpoint.Id);
}
} }
this.Notifications.success('Application successfully removed', application.Name); this.Notifications.success('Application successfully removed', application.Name);
const index = this.state.applications.indexOf(application); const index = this.state.applications.indexOf(application);

View File

@ -21,6 +21,8 @@
class-name="text-muted" class-name="text-muted"
url="ctrl.stack.GitConfig.URL" url="ctrl.stack.GitConfig.URL"
config-file-path="ctrl.stack.GitConfig.ConfigFilePath" config-file-path="ctrl.stack.GitConfig.ConfigFilePath"
additional-files="ctrl.stack.AdditionalFiles"
type="application"
></git-form-info-panel> ></git-form-info-panel>
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> <div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
Namespace Namespace
@ -56,11 +58,11 @@
<!-- #endregion --> <!-- #endregion -->
<!-- #region Git repository --> <!-- #region Git repository -->
<kubernetes-app-git-form <kubernetes-redeploy-app-git-form
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT"
stack="ctrl.stack" stack="ctrl.stack"
namespace="ctrl.formValues.ResourcePool.Namespace.Name" namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></kubernetes-app-git-form> ></kubernetes-redeploy-app-git-form>
<!-- #endregion --> <!-- #endregion -->
<!-- #region web editor --> <!-- #region web editor -->

View File

@ -29,6 +29,13 @@
</div> </div>
</div> </div>
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-lg-11 col-sm-10">
<input type="text" class="form-control" ng-model="ctrl.formValues.StackName" id="stack_name" placeholder="my-app" auto-focus />
</div>
</div>
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Build method Build method
</div> </div>
@ -42,32 +49,16 @@
</div> </div>
<!-- repository --> <!-- repository -->
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.GIT"> <git-form
<div class="col-sm-12 form-section-title"> ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.GIT"
Git repository model="ctrl.formValues"
</div> on-change="(ctrl.onChangeFormValues)"
<git-form-url-field value="ctrl.formValues.RepositoryURL" on-change="(ctrl.onRepoUrlChange)"></git-form-url-field> additional-file="true"
<git-form-ref-field value="ctrl.formValues.RepositoryReferenceName" on-change="(ctrl.onRepoRefChange)"></git-form-ref-field> auto-update="true"
<div class="form-group"> show-auth-explanation="true"
<span class="col-sm-12 text-muted small"> path-text-title="Manifest path"
Indicate the path to the yaml file from the root of your repository. path-placeholder="deployment.yml"
</span> ></git-form>
</div>
<div class="form-group">
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Manifest path</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
ng-model="ctrl.formValues.FilePathInRepository"
id="stack_manifest_path"
placeholder="deployment.yml"
data-cy="k8sAppDeploy-gitManifestPath"
/>
</div>
</div>
<git-form-auth-fieldset model="ctrl.formValues" on-change="(ctrl.onChangeFormValues)"></git-form-auth-fieldset>
</div>
<!-- !repository --> <!-- !repository -->
<custom-template-selector <custom-template-selector

View File

@ -1,23 +1,37 @@
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash-es'; import _ from 'lodash-es';
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import uuidv4 from 'uuid/v4';
import PortainerError from 'Portainer/error'; import PortainerError from 'Portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods } from 'Kubernetes/models/deploy'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { buildOption } from '@/portainer/components/box-selector'; import { buildOption } from '@/portainer/components/box-selector';
class KubernetesDeployController { class KubernetesDeployController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, $window, Authentication, CustomTemplateService, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) { constructor(
$async,
$state,
$window,
Authentication,
ModalService,
Notifications,
EndpointProvider,
KubernetesResourcePoolService,
StackService,
WebhookHelper,
CustomTemplateService
) {
this.$async = $async; this.$async = $async;
this.$state = $state; this.$state = $state;
this.$window = $window; this.$window = $window;
this.Authentication = Authentication; this.Authentication = Authentication;
this.CustomTemplateService = CustomTemplateService;
this.ModalService = ModalService; this.ModalService = ModalService;
this.Notifications = Notifications; this.Notifications = Notifications;
this.EndpointProvider = EndpointProvider; this.EndpointProvider = EndpointProvider;
this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.StackService = StackService; this.StackService = StackService;
this.WebhookHelper = WebhookHelper;
this.CustomTemplateService = CustomTemplateService;
this.deployOptions = [ this.deployOptions = [
buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES), buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES),
@ -41,7 +55,20 @@ class KubernetesDeployController {
templateId: null, templateId: null,
}; };
this.formValues = {}; this.formValues = {
StackName: '',
RepositoryURL: '',
RepositoryReferenceName: '',
RepositoryAuthentication: true,
RepositoryUsername: '',
RepositoryPassword: '',
AdditionalFiles: [],
ComposeFilePathInRepository: 'deployment.yml',
RepositoryAutomaticUpdates: true,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: this.WebhookHelper.returnStackWebhookUrl(uuidv4()),
};
this.ManifestDeployTypes = KubernetesDeployManifestTypes; this.ManifestDeployTypes = KubernetesDeployManifestTypes;
this.BuildMethods = KubernetesDeployBuildMethods; this.BuildMethods = KubernetesDeployBuildMethods;
this.endpointId = this.EndpointProvider.endpointID(); this.endpointId = this.EndpointProvider.endpointID();
@ -51,8 +78,6 @@ class KubernetesDeployController {
this.onChangeFileContent = this.onChangeFileContent.bind(this); this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.getNamespacesAsync = this.getNamespacesAsync.bind(this); this.getNamespacesAsync = this.getNamespacesAsync.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this); this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.onRepoUrlChange = this.onRepoUrlChange.bind(this);
this.onRepoRefChange = this.onRepoRefChange.bind(this);
this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this); this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this);
} }
@ -61,6 +86,7 @@ class KubernetesDeployController {
type: buildLabel(this.state.BuildMethod), type: buildLabel(this.state.BuildMethod),
format: formatLabel(this.state.DeployType), format: formatLabel(this.state.DeployType),
role: roleLabel(this.Authentication.isAdmin()), role: roleLabel(this.Authentication.isAdmin()),
'automatic-updates': automaticUpdatesLabel(this.formValues.RepositoryAutomaticUpdates, this.formValues.RepositoryMechanism),
}; };
if (this.state.BuildMethod === KubernetesDeployBuildMethods.GIT) { if (this.state.BuildMethod === KubernetesDeployBuildMethods.GIT) {
@ -69,6 +95,17 @@ class KubernetesDeployController {
return { metadata }; return { metadata };
function automaticUpdatesLabel(repositoryAutomaticUpdates, repositoryMechanism) {
switch (repositoryAutomaticUpdates && repositoryMechanism) {
case RepositoryMechanismTypes.INTERVAL:
return 'polling';
case RepositoryMechanismTypes.WEBHOOK:
return 'webhook';
default:
return 'off';
}
}
function roleLabel(isAdmin) { function roleLabel(isAdmin) {
if (isAdmin) { if (isAdmin) {
return 'admin'; return 'admin';
@ -99,17 +136,14 @@ class KubernetesDeployController {
disableDeploy() { disableDeploy() {
const isGitFormInvalid = const isGitFormInvalid =
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT && this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
(!this.formValues.RepositoryURL || (!this.formValues.RepositoryURL || !this.formValues.FilePathInRepository || (this.formValues.RepositoryAuthentication && !this.formValues.RepositoryPassword)) &&
!this.formValues.FilePathInRepository ||
(this.formValues.RepositoryAuthentication && (!this.formValues.RepositoryUsername || !this.formValues.RepositoryPassword))) &&
_.isEmpty(this.formValues.Namespace); _.isEmpty(this.formValues.Namespace);
const isWebEditorInvalid = const isWebEditorInvalid =
this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent) && _.isEmpty(this.formValues.Namespace); this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent) && _.isEmpty(this.formValues.Namespace);
const isURLFormInvalid = this.state.BuildMethod == KubernetesDeployBuildMethods.WEB_EDITOR.URL && _.isEmpty(this.formValues.ManifestURL); const isURLFormInvalid = this.state.BuildMethod == KubernetesDeployBuildMethods.WEB_EDITOR.URL && _.isEmpty(this.formValues.ManifestURL);
const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace); const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace);
return !this.formValues.StackName || isGitFormInvalid || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid;
return isGitFormInvalid || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid;
} }
onChangeFormValues(values) { onChangeFormValues(values) {
@ -119,14 +153,6 @@ class KubernetesDeployController {
}; };
} }
onRepoUrlChange(value) {
this.onChangeFormValues({ RepositoryURL: value });
}
onRepoRefChange(value) {
this.onChangeFormValues({ RepositoryReferenceName: value });
}
onChangeTemplateId(templateId) { onChangeTemplateId(templateId) {
return this.$async(async () => { return this.$async(async () => {
if (this.state.templateId === templateId) { if (this.state.templateId === templateId) {
@ -184,6 +210,7 @@ class KubernetesDeployController {
const payload = { const payload = {
ComposeFormat: composeFormat, ComposeFormat: composeFormat,
Namespace: this.formValues.Namespace, Namespace: this.formValues.Namespace,
StackName: this.formValues.StackName,
}; };
if (method === KubernetesDeployRequestMethods.REPOSITORY) { if (method === KubernetesDeployRequestMethods.REPOSITORY) {
@ -194,7 +221,16 @@ class KubernetesDeployController {
payload.RepositoryUsername = this.formValues.RepositoryUsername; payload.RepositoryUsername = this.formValues.RepositoryUsername;
payload.RepositoryPassword = this.formValues.RepositoryPassword; payload.RepositoryPassword = this.formValues.RepositoryPassword;
} }
payload.FilePathInRepository = this.formValues.FilePathInRepository; payload.ManifestFile = this.formValues.ComposeFilePathInRepository;
payload.AdditionalFiles = this.formValues.AdditionalFiles;
if (this.formValues.RepositoryAutomaticUpdates) {
payload.AutoUpdate = {};
if (this.formValues.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
payload.AutoUpdate.Interval = this.formValues.RepositoryFetchInterval;
} else if (this.formValues.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
payload.AutoUpdate.Webhook = this.formValues.RepositoryWebhookURL.split('/').reverse()[0];
}
}
} else if (method === KubernetesDeployRequestMethods.STRING) { } else if (method === KubernetesDeployRequestMethods.STRING) {
payload.StackFileContent = this.formValues.EditorContent; payload.StackFileContent = this.formValues.EditorContent;
} else { } else {

View File

@ -1,6 +1,6 @@
<div class="col-sm-12 small text-muted"> <div class="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one table to the You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one table
other. to the other.
</div> </div>
<div class="col-sm-12" style="margin-top: 20px;"> <div class="col-sm-12" style="margin-top: 20px;">
<!-- available-endpoints --> <!-- available-endpoints -->

View File

@ -0,0 +1,11 @@
.inline-label {
display: inline-block;
padding: 0 15px;
min-width: 200px;
}
.inline-input {
display: inline-block;
margin-left: 15px;
width: calc(100% - 235px);
}

View File

@ -15,8 +15,10 @@
</div> </div>
<div ng-if="$ctrl.model.RepositoryAuthentication"> <div ng-if="$ctrl.model.RepositoryAuthentication">
<div class="form-group"> <div class="form-group">
<label for="repository_username" class="col-sm-2 control-label text-left">Username</label> <label for="repository_username" class="control-label text-left inline-label">
<div class="col-sm-3"> Username
</label>
<div class="inline-input">
<input <input
type="text" type="text"
class="form-control" class="form-control"
@ -29,11 +31,11 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="repository_password" class="col-sm-2 control-label text-left"> <label for="repository_password" class="control-label text-left inline-label">
Personal Access Token Personal Access Token
<portainer-tooltip position="bottom" message="Provide a personal access token or password"></portainer-tooltip> <portainer-tooltip position="bottom" message="Provide a personal access token or password"></portainer-tooltip>
</label> </label>
<div class="col-sm-3"> <div class="inline-input">
<input <input
type="password" type="password"
class="form-control" class="form-control"

View File

@ -1,4 +1,5 @@
import controller from './git-form-auth-fieldset.controller.js'; import controller from './git-form-auth-fieldset.controller.js';
import './git-form-auth-fieldset.css';
export const gitFormAuthFieldset = { export const gitFormAuthFieldset = {
templateUrl: './git-form-auth-fieldset.html', templateUrl: './git-form-auth-fieldset.html',

View File

@ -2,7 +2,6 @@ export const gitFormComposePathField = {
templateUrl: './git-form-compose-path-field.html', templateUrl: './git-form-compose-path-field.html',
bindings: { bindings: {
deployMethod: '@', deployMethod: '@',
value: '<', value: '<',
onChange: '<', onChange: '<',
}, },

View File

@ -1,15 +1,15 @@
<div class="form-group" ng-class="$ctrl.className"> <div class="form-group" ng-class="$ctrl.className">
<div class="col-sm-12"> <div class="col-sm-12">
<p> <p>
This stack was deployed from the git repository <code>{{ $ctrl.url }}</code> This {{ $ctrl.type }} was deployed from the git repository <code>{{ $ctrl.url }}</code>
. .
</p> </p>
<p> <p>
Update Update
<code <code
>{{ $ctrl.configFilePath }}<span ng-if="$ctrl.additionalFiles.length > 0">,{{ $ctrl.additionalFiles.join(',') }}</span></code >{{ $ctrl.configFilePath }}<span ng-if="$ctrl.additionalFiles.length > 0">, {{ $ctrl.additionalFiles.join(',') }}</span></code
> >
in git and pull from here to update the stack. in git and pull from here to update the {{ $ctrl.type }}.
</p> </p>
</div> </div>
</div> </div>

View File

@ -5,5 +5,6 @@ export const gitFormInfoPanel = {
configFilePath: '<', configFilePath: '<',
additionalFiles: '<', additionalFiles: '<',
className: '@', className: '@',
type: '@',
}, },
}; };

View File

@ -43,8 +43,8 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 small text-muted"> <div class="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one table to You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one
the other. table to the other.
</div> </div>
<div class="col-sm-12" style="margin-top: 20px;"> <div class="col-sm-12" style="margin-top: 20px;">
<!-- available-endpoints --> <!-- available-endpoints -->

View File

@ -83,7 +83,6 @@ class KubernetesAppGitFormController {
} }
$onInit() { $onInit() {
console.log(this);
this.formValues.RefName = this.stack.GitConfig.ReferenceName; this.formValues.RefName = this.stack.GitConfig.ReferenceName;
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) { if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username; this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;

View File

@ -0,0 +1,147 @@
import uuidv4 from 'uuid/v4';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
class KubernetesRedeployAppGitFormController {
/* @ngInject */
constructor($async, $state, StackService, ModalService, Notifications, WebhookHelper) {
this.$async = $async;
this.$state = $state;
this.StackService = StackService;
this.ModalService = ModalService;
this.Notifications = Notifications;
this.WebhookHelper = WebhookHelper;
this.state = {
saveGitSettingsInProgress: false,
redeployInProgress: false,
showConfig: false,
isEdit: false,
hasUnsavedChanges: false,
};
this.formValues = {
RefName: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
// auto update
AutoUpdate: {
RepositoryAutomaticUpdates: false,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: '',
},
};
this.onChange = this.onChange.bind(this);
this.onChangeRef = this.onChangeRef.bind(this);
}
onChangeRef(value) {
this.onChange({ RefName: value });
}
onChange(values) {
this.formValues = {
...this.formValues,
...values,
};
this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
}
buildAnalyticsProperties() {
const metadata = {
'automatic-updates': automaticUpdatesLabel(this.formValues.AutoUpdate.RepositoryAutomaticUpdates, this.formValues.AutoUpdate.RepositoryMechanism),
};
return { metadata };
function automaticUpdatesLabel(repositoryAutomaticUpdates, repositoryMechanism) {
switch (repositoryAutomaticUpdates && repositoryMechanism) {
case RepositoryMechanismTypes.INTERVAL:
return 'polling';
case RepositoryMechanismTypes.WEBHOOK:
return 'webhook';
default:
return 'off';
}
}
}
async pullAndRedeployApplication() {
return this.$async(async () => {
try {
const confirmed = await this.ModalService.confirmAsync({
title: 'Are you sure?',
message: 'Any changes to this application will be overriden by the definition in git and may cause a service interruption. Do you wish to continue?',
buttons: {
confirm: {
label: 'Update',
className: 'btn-warning',
},
},
});
if (!confirmed) {
return;
}
this.state.redeployInProgress = true;
await this.StackService.updateKubeGit(this.stack.Id, this.stack.EndpointId, this.namespace, this.formValues);
this.Notifications.success('Pulled and redeployed stack successfully');
await this.$state.reload();
} catch (err) {
this.Notifications.error('Failure', err, 'Failed redeploying application');
} finally {
this.state.redeployInProgress = false;
}
});
}
async saveGitSettings() {
return this.$async(async () => {
try {
this.state.saveGitSettingsInProgress = true;
await this.StackService.updateKubeStack({ EndpointId: this.stack.EndpointId, Id: this.stack.Id }, null, this.formValues);
this.savedFormValues = angular.copy(this.formValues);
this.state.hasUnsavedChanges = false;
this.Notifications.success('Save stack settings successfully');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to save application settings');
} finally {
this.state.saveGitSettingsInProgress = false;
}
});
}
isSubmitButtonDisabled() {
return this.state.saveGitSettingsInProgress || this.state.redeployInProgress;
}
$onInit() {
this.formValues.RefName = this.stack.GitConfig.ReferenceName;
// Init auto update
if (this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook)) {
this.formValues.AutoUpdate.RepositoryAutomaticUpdates = true;
if (this.stack.AutoUpdate.Interval) {
this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.INTERVAL;
this.formValues.AutoUpdate.RepositoryFetchInterval = this.stack.AutoUpdate.Interval;
} else if (this.stack.AutoUpdate.Webhook) {
this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.WEBHOOK;
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(this.stack.AutoUpdate.Webhook);
}
}
if (!this.formValues.AutoUpdate.RepositoryWebhookURL) {
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(uuidv4());
}
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
this.formValues.RepositoryAuthentication = true;
this.state.isEdit = true;
}
this.savedFormValues = angular.copy(this.formValues);
}
}
export default KubernetesRedeployAppGitFormController;

View File

@ -0,0 +1,64 @@
<form name="$ctrl.redeployGitForm">
<div class="col-sm-12 form-section-title">
Redeploy from git repository
</div>
<div class="form-group text-muted">
<div class="col-sm-12">
<p>
Pull the latest manifest from git and redeploy the application.
</p>
</div>
</div>
<git-form-auto-update-fieldset model="$ctrl.formValues.AutoUpdate" on-change="($ctrl.onChange)"></git-form-auto-update-fieldset>
<div class="form-group">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-click="$ctrl.state.showConfig = !$ctrl.state.showConfig">
<i ng-class="['fa space-right', { 'fa-minus': $ctrl.state.showConfig, 'fa-plus': !$ctrl.state.showConfig }]" aria-hidden="true"></i>
{{ $ctrl.state.showConfig ? 'Hide' : 'Advanced' }} configuration
</a>
</p>
</div>
</div>
<git-form-ref-field ng-if="$ctrl.state.showConfig" value="$ctrl.formValues.RefName" on-change="($ctrl.onChangeRef)"></git-form-ref-field>
<git-form-auth-fieldset
ng-if="$ctrl.state.showConfig"
model="$ctrl.formValues"
is-edit="$ctrl.state.isEdit"
on-change="($ctrl.onChange)"
show-auth-explanation="true"
></git-form-auth-fieldset>
<div class="col-sm-12 form-section-title">
Actions
</div>
<!-- #Git buttons -->
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.pullAndRedeployApplication()"
ng-if="!$ctrl.formValues.AutoUpdate.RepositoryAutomaticUpdates"
ng-disabled="$ctrl.isSubmitButtonDisabled() || $ctrl.state.hasUnsavedChanges|| !$ctrl.redeployGitForm.$valid"
style="margin-top: 7px; margin-left: 0;"
button-spinner="$ctrl.state.redeployInProgress"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-application-edit-git-pull"
>
<span ng-show="!$ctrl.state.redeployInProgress"> <i class="fa fa-sync space-right" aria-hidden="true"></i> Pull and update application </span>
<span ng-show="$ctrl.state.redeployInProgress">In progress...</span>
</button>
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.saveGitSettings()"
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.state.hasUnsavedChanges|| !$ctrl.redeployGitForm.$valid"
style="margin-top: 7px; margin-left: 0;"
button-spinner="$ctrl.state.saveGitSettingsInProgress"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-application-edit"
analytics-properties="$ctrl.buildAnalyticsProperties()"
>
<span ng-show="!$ctrl.state.saveGitSettingsInProgress"> Save settings </span>
<span ng-show="$ctrl.state.saveGitSettingsInProgress">In progress...</span>
</button>
</form>

View File

@ -0,0 +1,13 @@
import angular from 'angular';
import controller from './kubernetes-redeploy-app-git-form.controller';
const kubernetesRedeployAppGitForm = {
templateUrl: './kubernetes-redeploy-app-git-form.html',
controller,
bindings: {
stack: '<',
namespace: '<',
},
};
angular.module('portainer.app').component('kubernetesRedeployAppGitForm', kubernetesRedeployAppGitForm);

View File

@ -1,5 +1,5 @@
import uuidv4 from 'uuid/v4'; import uuidv4 from 'uuid/v4';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
class StackRedeployGitFormController { class StackRedeployGitFormController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, StackService, ModalService, Notifications, WebhookHelper, FormHelper) { constructor($async, $state, StackService, ModalService, Notifications, WebhookHelper, FormHelper) {
@ -28,7 +28,7 @@ class StackRedeployGitFormController {
// auto update // auto update
AutoUpdate: { AutoUpdate: {
RepositoryAutomaticUpdates: false, RepositoryAutomaticUpdates: false,
RepositoryMechanism: 'Interval', RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m', RepositoryFetchInterval: '5m',
RepositoryWebhookURL: '', RepositoryWebhookURL: '',
}, },
@ -50,9 +50,9 @@ class StackRedeployGitFormController {
function autoSyncLabel(type) { function autoSyncLabel(type) {
switch (type) { switch (type) {
case 'Interval': case RepositoryMechanismTypes.INTERVAL:
return 'polling'; return 'polling';
case 'Webhook': case RepositoryMechanismTypes.WEBHOOK:
return 'webhook'; return 'webhook';
} }
return 'off'; return 'off';
@ -156,10 +156,10 @@ class StackRedeployGitFormController {
this.formValues.AutoUpdate.RepositoryAutomaticUpdates = true; this.formValues.AutoUpdate.RepositoryAutomaticUpdates = true;
if (this.stack.AutoUpdate.Interval) { if (this.stack.AutoUpdate.Interval) {
this.formValues.AutoUpdate.RepositoryMechanism = `Interval`; this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.INTERVAL;
this.formValues.AutoUpdate.RepositoryFetchInterval = this.stack.AutoUpdate.Interval; this.formValues.AutoUpdate.RepositoryFetchInterval = this.stack.AutoUpdate.Interval;
} else if (this.stack.AutoUpdate.Webhook) { } else if (this.stack.AutoUpdate.Webhook) {
this.formValues.AutoUpdate.RepositoryMechanism = `Webhook`; this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.WEBHOOK;
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(this.stack.AutoUpdate.Webhook); this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(this.stack.AutoUpdate.Webhook);
} }
} }

View File

@ -1,4 +1,5 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { StackViewModel, OrphanedStackViewModel } from '../../models/stack'; import { StackViewModel, OrphanedStackViewModel } from '../../models/stack';
angular.module('portainer.app').factory('StackService', [ angular.module('portainer.app').factory('StackService', [
@ -277,7 +278,17 @@ angular.module('portainer.app').factory('StackService', [
StackFileContent: stackFile, StackFileContent: stackFile,
}; };
} else { } else {
const autoUpdate = {};
if (gitConfig.AutoUpdate && gitConfig.AutoUpdate.RepositoryAutomaticUpdates) {
if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
autoUpdate.Interval = gitConfig.AutoUpdate.RepositoryFetchInterval;
} else if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
autoUpdate.Webhook = gitConfig.AutoUpdate.RepositoryWebhookURL.split('/').reverse()[0];
}
}
payload = { payload = {
AutoUpdate: autoUpdate,
RepositoryReferenceName: gitConfig.RefName, RepositoryReferenceName: gitConfig.RefName,
RepositoryAuthentication: gitConfig.RepositoryAuthentication, RepositoryAuthentication: gitConfig.RepositoryAuthentication,
RepositoryUsername: gitConfig.RepositoryUsername, RepositoryUsername: gitConfig.RepositoryUsername,
@ -455,9 +466,9 @@ angular.module('portainer.app').factory('StackService', [
const autoUpdate = {}; const autoUpdate = {};
if (gitConfig.AutoUpdate.RepositoryAutomaticUpdates) { if (gitConfig.AutoUpdate.RepositoryAutomaticUpdates) {
if (gitConfig.AutoUpdate.RepositoryMechanism === 'Interval') { if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
autoUpdate.Interval = gitConfig.AutoUpdate.RepositoryFetchInterval; autoUpdate.Interval = gitConfig.AutoUpdate.RepositoryFetchInterval;
} else if (gitConfig.AutoUpdate.RepositoryMechanism === 'Webhook') { } else if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
autoUpdate.Webhook = gitConfig.AutoUpdate.RepositoryWebhookURL.split('/').reverse()[0]; autoUpdate.Webhook = gitConfig.AutoUpdate.RepositoryWebhookURL.split('/').reverse()[0];
} }
} }

View File

@ -30,7 +30,7 @@ class CreateCustomTemplateViewController {
ComposeFilePathInRepository: 'docker-compose.yml', ComposeFilePathInRepository: 'docker-compose.yml',
Description: '', Description: '',
Note: '', Note: '',
Logo:'', Logo: '',
Platform: 1, Platform: 1,
Type: 1, Type: 1,
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),

View File

@ -9,6 +9,13 @@
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<groups-datatable title-text="Environment groups" title-icon="fa-object-group" dataset="groups" table-key="groups" order-by="Name" remove-action="removeAction"></groups-datatable> <groups-datatable
title-text="Environment groups"
title-icon="fa-object-group"
dataset="groups"
table-key="groups"
order-by="Name"
remove-action="removeAction"
></groups-datatable>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import uuidv4 from 'uuid/v4'; import uuidv4 from 'uuid/v4';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel';
angular angular
@ -42,7 +42,7 @@ angular
ComposeFilePathInRepository: 'docker-compose.yml', ComposeFilePathInRepository: 'docker-compose.yml',
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),
RepositoryAutomaticUpdates: true, RepositoryAutomaticUpdates: true,
RepositoryMechanism: 'Interval', RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m', RepositoryFetchInterval: '5m',
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()), RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
}; };
@ -111,9 +111,9 @@ angular
function autoSyncLabel(type) { function autoSyncLabel(type) {
switch (type) { switch (type) {
case 'Interval': case RepositoryMechanismTypes.INTERVAL:
return 'polling'; return 'polling';
case 'Webhook': case RepositoryMechanismTypes.WEBHOOK:
return 'webhook'; return 'webhook';
} }
return 'off'; return 'off';
@ -166,9 +166,9 @@ angular
function getAutoUpdatesProperty(repositoryOptions) { function getAutoUpdatesProperty(repositoryOptions) {
if ($scope.formValues.RepositoryAutomaticUpdates) { if ($scope.formValues.RepositoryAutomaticUpdates) {
repositoryOptions.AutoUpdate = {}; repositoryOptions.AutoUpdate = {};
if ($scope.formValues.RepositoryMechanism === 'Interval') { if ($scope.formValues.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
repositoryOptions.AutoUpdate.Interval = $scope.formValues.RepositoryFetchInterval; repositoryOptions.AutoUpdate.Interval = $scope.formValues.RepositoryFetchInterval;
} else if ($scope.formValues.RepositoryMechanism === 'Webhook') { } else if ($scope.formValues.RepositoryMechanism === RepositoryMechanismTypes.WEBHOOK) {
repositoryOptions.AutoUpdate.Webhook = $scope.formValues.RepositoryWebhookURL.split('/').reverse()[0]; repositoryOptions.AutoUpdate.Webhook = $scope.formValues.RepositoryWebhookURL.split('/').reverse()[0];
} }
} }

View File

@ -113,6 +113,8 @@
additional-file="true" additional-file="true"
auto-update="true" auto-update="true"
show-auth-explanation="true" show-auth-explanation="true"
path-text-title="Compose path"
path-placeholder="docker-compose.yml"
></git-form> ></git-form>
<custom-template-selector <custom-template-selector