Compare commits
58 Commits
feat/EE-35
...
fix/EE-169
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bad61e73e | ||
|
|
1cadfb1b5f | ||
|
|
bd82c5e14e | ||
|
|
bda164fca4 | ||
|
|
b090143ef1 | ||
|
|
6e200a03ba | ||
|
|
363c65a7ec | ||
|
|
235224975d | ||
|
|
cea0533d14 | ||
|
|
e066681742 | ||
|
|
a77f189b45 | ||
|
|
c83082f447 | ||
|
|
ba78168d5c | ||
|
|
5add05ad6b | ||
|
|
3225136a15 | ||
|
|
25b6b3467b | ||
|
|
cbfdd58ece | ||
|
|
5d2fe2d818 | ||
|
|
0c87634bc3 | ||
|
|
1991475437 | ||
|
|
f45edaaa76 | ||
|
|
85eca81584 | ||
|
|
1faed11fd6 | ||
|
|
042a66d15c | ||
|
|
3a066d0cd8 | ||
|
|
f3b8a9dc85 | ||
|
|
0ee42f76c3 | ||
|
|
6fe6f36696 | ||
|
|
3145b4007a | ||
|
|
93a77fd80c | ||
|
|
c809a5dbf2 | ||
|
|
8321318143 | ||
|
|
f631c1757b | ||
|
|
f540375eb7 | ||
|
|
8449f895e9 | ||
|
|
d191e4f9b9 | ||
|
|
048bd35dfb | ||
|
|
d6dbb3982a | ||
|
|
182bf10b91 | ||
|
|
740993e3a4 | ||
|
|
b94bd2e96f | ||
|
|
b2da6101b6 | ||
|
|
b43fb6b5e6 | ||
|
|
b3b168631d | ||
|
|
1cbfd96738 | ||
|
|
5e898405f5 | ||
|
|
ed500b51c6 | ||
|
|
0e60f40937 | ||
|
|
a5058e8f1e | ||
|
|
b5c59c8982 | ||
|
|
47c32df77a | ||
|
|
ee213a6c4a | ||
|
|
0979e6ec8f | ||
|
|
840d65f578 | ||
|
|
0128d1bf96 | ||
|
|
87ef8092ba | ||
|
|
6d87c77ab0 | ||
|
|
9fae031390 |
@@ -56,7 +56,33 @@ func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *porta
|
||||
}
|
||||
}
|
||||
|
||||
// SetTunnelStatusToActive update the status of the tunnel associated to the specified environment(endpoint).
|
||||
// GetActiveTunnel retrieves an active tunnel which allows communicating with edge agent
|
||||
func (service *Service) GetActiveTunnel(endpoint *portainer.Endpoint) (*portainer.TunnelDetails, error) {
|
||||
tunnel := service.GetTunnelDetails(endpoint.ID)
|
||||
if tunnel.Status == portainer.EdgeAgentIdle || tunnel.Status == portainer.EdgeAgentManagementRequired {
|
||||
err := service.SetTunnelStatusToRequired(endpoint.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed opening tunnel to endpoint: %w", err)
|
||||
}
|
||||
|
||||
if endpoint.EdgeCheckinInterval == 0 {
|
||||
settings, err := service.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed fetching settings from db: %w", err)
|
||||
}
|
||||
|
||||
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
waitForAgentToConnect := time.Duration(endpoint.EdgeCheckinInterval) * time.Second
|
||||
time.Sleep(waitForAgentToConnect * 2)
|
||||
}
|
||||
tunnel = service.GetTunnelDetails(endpoint.ID)
|
||||
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
// SetTunnelStatusToActive update the status of the tunnel associated to the specified endpoint.
|
||||
// It sets the status to ACTIVE.
|
||||
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {
|
||||
tunnel := service.GetTunnelDetails(endpointID)
|
||||
|
||||
@@ -99,8 +99,8 @@ func initSwarmStackManager(assetsPath string, configPath string, signatureServic
|
||||
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath)
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||
}
|
||||
|
||||
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
|
||||
@@ -469,7 +469,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatalf("failed initializing swarm stack manager: %s", err)
|
||||
}
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||
if err != nil {
|
||||
@@ -542,7 +542,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
|
||||
scheduler := scheduler.NewScheduler(shutdownCtx)
|
||||
stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager)
|
||||
stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer)
|
||||
stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
|
||||
|
||||
return &http.Server{
|
||||
|
||||
@@ -47,7 +47,7 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
|
||||
func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := manager.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to featch environment proxy")
|
||||
return errors.Wrap(err, "failed to fetch environment proxy")
|
||||
}
|
||||
|
||||
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
|
||||
func (w *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
func (manager *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (manager *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpo
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
proxy, err := manager.proxyManager.CreateComposeProxyServer(endpoint)
|
||||
proxy, err := manager.proxyManager.CreateAgentProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -2,24 +2,20 @@ package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
)
|
||||
|
||||
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment(endpoint).
|
||||
@@ -30,10 +26,11 @@ type KubernetesDeployer struct {
|
||||
signatureService portainer.DigitalSignatureService
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
|
||||
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
|
||||
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, binaryPath string) *KubernetesDeployer {
|
||||
return &KubernetesDeployer{
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
@@ -41,32 +38,33 @@ func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheMan
|
||||
signatureService: signatureService,
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
proxyManager: proxyManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
kubecli, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) {
|
||||
kubeCLI, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
|
||||
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubecli, deployer.dataStore, tokenCache, setLocalAdminToken)
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubeCLI, deployer.dataStore, tokenCache, setLocalAdminToken)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID)
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(user.ID), endpoint.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -77,156 +75,62 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes environment(endpoint).
|
||||
// Otherwise it will use kubectl to deploy the manifest.
|
||||
func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment {
|
||||
token, err := deployer.getToken(request, endpoint, true)
|
||||
// Deploy upserts Kubernetes resources defined in manifest(s)
|
||||
func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []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")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--token", token,
|
||||
"--namespace", namespace,
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
url, proxy, err := deployer.getAgentURL(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", errors.WithMessage(err, "failed generating endpoint URL")
|
||||
}
|
||||
|
||||
command := path.Join(deployer.binaryPath, "kubectl")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--server", endpoint.URL)
|
||||
defer proxy.Close()
|
||||
args = append(args, "--server", url)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
args = append(args, "--token", token)
|
||||
args = append(args, "--namespace", namespace)
|
||||
args = append(args, "apply", "-f", "-")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = strings.NewReader(stackConfig)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", errors.New(stderr.String())
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// agent
|
||||
|
||||
endpointURL := endpoint.URL
|
||||
if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
tunnel := deployer.reverseTunnelService.GetTunnelDetails(endpoint.ID)
|
||||
if tunnel.Status == portainer.EdgeAgentIdle {
|
||||
|
||||
err := deployer.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
settings, err := deployer.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
waitForAgentToConnect := time.Duration(settings.EdgeAgentCheckinInterval) * time.Second
|
||||
time.Sleep(waitForAgentToConnect * 2)
|
||||
}
|
||||
|
||||
endpointURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
|
||||
if operation == "delete" {
|
||||
args = append(args, "--ignore-not-found=true")
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
transport.TLSClientConfig = tlsConfig
|
||||
args = append(args, operation)
|
||||
for _, path := range manifestFiles {
|
||||
args = append(args, "-f", strings.TrimSpace(path))
|
||||
}
|
||||
|
||||
httpCli := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if !strings.HasPrefix(endpointURL, "http") {
|
||||
endpointURL = fmt.Sprintf("https://%s", endpointURL)
|
||||
}
|
||||
|
||||
url, err := url.Parse(fmt.Sprintf("%s/v2/kubernetes/stack", endpointURL))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", errors.Wrapf(err, "failed to execute kubectl command: %q", stderr.String())
|
||||
}
|
||||
|
||||
reqPayload, err := json.Marshal(
|
||||
struct {
|
||||
StackConfig string
|
||||
Namespace string
|
||||
}{
|
||||
StackConfig: stackConfig,
|
||||
Namespace: namespace,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url.String(), bytes.NewReader(reqPayload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signature, err := deployer.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := deployer.getToken(request, endpoint, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
|
||||
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
req.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
resp, err := httpCli.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errorResponseData struct {
|
||||
Message string
|
||||
Details string
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&errorResponseData)
|
||||
if err != nil {
|
||||
output, parseStringErr := ioutil.ReadAll(resp.Body)
|
||||
if parseStringErr != nil {
|
||||
return "", parseStringErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Failed parsing, body: %s, error: %w", output, err)
|
||||
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Deployment to agent failed: %s", errorResponseData.Details)
|
||||
}
|
||||
|
||||
var responseData struct{ Output string }
|
||||
err = json.NewDecoder(resp.Body).Decode(&responseData)
|
||||
if err != nil {
|
||||
parsedOutput, parseStringErr := ioutil.ReadAll(resp.Body)
|
||||
if parseStringErr != nil {
|
||||
return "", parseStringErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Failed decoding, body: %s, err: %w", parsedOutput, err)
|
||||
}
|
||||
|
||||
return responseData.Output, nil
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// ConvertCompose leverages the kompose binary to deploy a compose compliant manifest.
|
||||
@@ -251,3 +155,12 @@ func (deployer *KubernetesDeployer) ConvertCompose(data []byte) ([]byte, error)
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) getAgentURL(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
|
||||
proxy, err := deployer.proxyManager.CreateAgentProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://127.0.0.1:%d/kubernetes", proxy.Port), proxy, nil
|
||||
}
|
||||
|
||||
23
api/filesystem/write.go
Normal file
23
api/filesystem/write.go
Normal 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)
|
||||
}
|
||||
48
api/filesystem/write_test.go
Normal file
48
api/filesystem/write_test.go
Normal 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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
jobID = scheduler.StartJobEvery(d, func() {
|
||||
if err := stacks.RedeployWhenChanged(stackID, stackDeployer, datastore, gitService); err != nil {
|
||||
log.Printf("[ERROR] [http,stacks] [message: failed redeploying] [err: %s]\n", err)
|
||||
}
|
||||
jobID = scheduler.StartJobEvery(d, func() error {
|
||||
return stacks.RedeployWhenChanged(stackID, stackDeployer, datastore, gitService)
|
||||
})
|
||||
|
||||
return jobID, nil
|
||||
|
||||
@@ -46,7 +46,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, false)
|
||||
if err != nil {
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
if configErr != nil {
|
||||
@@ -281,7 +281,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||
|
||||
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 {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -19,16 +18,19 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
)
|
||||
|
||||
type kubernetesStringDeploymentPayload struct {
|
||||
StackName string
|
||||
ComposeFormat bool
|
||||
Namespace string
|
||||
StackFileContent string
|
||||
}
|
||||
|
||||
type kubernetesGitDeploymentPayload struct {
|
||||
StackName string
|
||||
ComposeFormat bool
|
||||
Namespace string
|
||||
RepositoryURL string
|
||||
@@ -36,10 +38,13 @@ type kubernetesGitDeploymentPayload struct {
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
FilePathInRepository string
|
||||
ManifestFile string
|
||||
AdditionalFiles []string
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
}
|
||||
|
||||
type kubernetesManifestURLDeploymentPayload struct {
|
||||
StackName string
|
||||
Namespace string
|
||||
ComposeFormat bool
|
||||
ManifestURL string
|
||||
@@ -52,6 +57,9 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
|
||||
if govalidator.IsNull(payload.Namespace) {
|
||||
return errors.New("Invalid namespace")
|
||||
}
|
||||
if govalidator.IsNull(payload.StackName) {
|
||||
return errors.New("Invalid stack name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,12 +73,18 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
|
||||
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
}
|
||||
if govalidator.IsNull(payload.FilePathInRepository) {
|
||||
return errors.New("Invalid file path in repository")
|
||||
if govalidator.IsNull(payload.ManifestFile) {
|
||||
return errors.New("Invalid manifest file in repository")
|
||||
}
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -78,6 +92,9 @@ func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request)
|
||||
if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) {
|
||||
return errors.New("Invalid manifest URL")
|
||||
}
|
||||
if govalidator.IsNull(payload.StackName) {
|
||||
return errors.New("Invalid stack name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,6 +112,13 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
if err != nil {
|
||||
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()
|
||||
stack := &portainer.Stack{
|
||||
@@ -102,6 +126,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
Type: portainer.KubernetesStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: filesystem.ManifestFileDefaultName,
|
||||
Name: payload.StackName,
|
||||
Namespace: payload.Namespace,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
@@ -124,11 +149,11 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "content",
|
||||
output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
StackName: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "content",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -140,8 +165,6 @@ 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}
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
|
||||
resp := &createKubernetesStackResponse{
|
||||
Output: output,
|
||||
}
|
||||
@@ -159,23 +182,44 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
if err != nil {
|
||||
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()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Type: portainer.KubernetesStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.FilePathInRepository,
|
||||
EntryPoint: payload.ManifestFile,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.FilePathInRepository,
|
||||
ConfigFilePath: payload.ManifestFile,
|
||||
},
|
||||
Namespace: payload.Namespace,
|
||||
Name: payload.StackName,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
CreatedBy: user.Username,
|
||||
IsComposeFormat: payload.ComposeFormat,
|
||||
AutoUpdate: payload.AutoUpdate,
|
||||
AdditionalFiles: payload.AdditionalFiles,
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
@@ -197,33 +241,48 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
}
|
||||
stack.GitConfig.ConfigHash = commitID
|
||||
|
||||
stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err}
|
||||
repositoryUsername := payload.RepositoryUsername
|
||||
repositoryPassword := payload.RepositoryPassword
|
||||
if !payload.RepositoryAuthentication {
|
||||
repositoryUsername = ""
|
||||
repositoryPassword = ""
|
||||
}
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "git",
|
||||
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,
|
||||
StackName: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "git",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
|
||||
resp := &createKubernetesStackResponse{
|
||||
Output: output,
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
@@ -237,6 +296,13 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
|
||||
if err != nil {
|
||||
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()
|
||||
stack := &portainer.Stack{
|
||||
@@ -245,6 +311,7 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: filesystem.ManifestFileDefaultName,
|
||||
Namespace: payload.Namespace,
|
||||
Name: payload.StackName,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
CreatedBy: user.Username,
|
||||
@@ -267,11 +334,11 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, string(manifestContent), payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "url",
|
||||
output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
StackName: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "url",
|
||||
})
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
@@ -291,42 +358,14 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
|
||||
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()
|
||||
defer handler.stackCreationMutex.Unlock()
|
||||
|
||||
manifest := []byte(stackConfig)
|
||||
if composeFormat {
|
||||
convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(manifest)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest")
|
||||
}
|
||||
manifest = convertedConfig
|
||||
}
|
||||
|
||||
manifest, err := k.AddAppLabels(manifest, appLabels)
|
||||
manifestFilePaths, tempDir, err := stackutils.CreateTempK8SDeploymentFiles(stack, handler.KubernetesDeployer, appLabels)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to add application labels")
|
||||
return "", errors.Wrap(err, "failed to create temp kub deployment files")
|
||||
}
|
||||
|
||||
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
|
||||
defer os.RemoveAll(tempDir)
|
||||
return handler.KubernetesDeployer.Deploy(userID, endpoint, manifestFilePaths, stack.Namespace)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -51,7 +51,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
|
||||
|
||||
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 {
|
||||
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)
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true)
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
if configErr != nil {
|
||||
@@ -298,7 +299,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
|
||||
|
||||
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 {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedR
|
||||
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()
|
||||
if err != nil {
|
||||
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, "")
|
||||
if err != nil {
|
||||
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) {
|
||||
|
||||
@@ -2,10 +2,11 @@ package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
@@ -34,12 +35,12 @@ import (
|
||||
func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveRouteVariableValue(r, "id")
|
||||
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)
|
||||
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)
|
||||
@@ -49,52 +50,52 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
|
||||
|
||||
id, err := strconv.Atoi(stackID)
|
||||
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))
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
|
||||
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))
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
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 {
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
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 {
|
||||
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 +105,26 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
}
|
||||
|
||||
err = handler.deleteStack(stack, endpoint)
|
||||
err = handler.deleteStack(securityContext.UserID, stack, endpoint)
|
||||
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))
|
||||
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 {
|
||||
err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID)
|
||||
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)
|
||||
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)
|
||||
@@ -132,31 +133,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 {
|
||||
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
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))
|
||||
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 {
|
||||
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)
|
||||
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{
|
||||
@@ -164,18 +165,24 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit
|
||||
Type: portainer.DockerSwarmStack,
|
||||
}
|
||||
|
||||
err = handler.deleteStack(stack, endpoint)
|
||||
err = handler.deleteStack(securityContext.UserID, stack, endpoint)
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return handler.SwarmStackManager.Remove(stack, endpoint)
|
||||
}
|
||||
|
||||
return handler.ComposeStackManager.Down(context.TODO(), stack, endpoint)
|
||||
if stack.Type == portainer.DockerComposeStack {
|
||||
return handler.ComposeStackManager.Down(context.TODO(), stack, endpoint)
|
||||
}
|
||||
if stack.Type == portainer.KubernetesStack {
|
||||
out, err := handler.KubernetesDeployer.Remove(userID, endpoint, stackutils.GetStackFilePaths(stack), stack.Namespace)
|
||||
return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out)
|
||||
}
|
||||
return fmt.Errorf("unsupported stack type: %v", stack.Type)
|
||||
}
|
||||
|
||||
@@ -50,52 +50,54 @@ func (payload *stackMigratePayload) Validate(r *http.Request) error {
|
||||
func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
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
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
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))
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", 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)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
|
||||
}
|
||||
if !access {
|
||||
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
|
||||
@@ -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.
|
||||
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
|
||||
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) {
|
||||
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))
|
||||
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 {
|
||||
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)
|
||||
@@ -126,14 +128,14 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
|
||||
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 {
|
||||
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 {
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running on environment '%s'", stack.Name, targetEndpoint.Name)
|
||||
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running on endpoint '%s'", stack.Name, targetEndpoint.Name)
|
||||
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: errorMessage, Err: errors.New(errorMessage)}
|
||||
}
|
||||
|
||||
migrationError := handler.migrateStack(r, stack, targetEndpoint)
|
||||
@@ -142,14 +144,14 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
|
||||
}
|
||||
|
||||
stack.Name = oldName
|
||||
err = handler.deleteStack(stack, endpoint)
|
||||
err = handler.deleteStack(securityContext.UserID, stack, endpoint)
|
||||
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)
|
||||
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 != "" {
|
||||
@@ -175,7 +177,7 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
|
||||
|
||||
err := handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -189,7 +191,7 @@ func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stac
|
||||
|
||||
err := handler.deploySwarmStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -33,59 +33,61 @@ import (
|
||||
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
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)
|
||||
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))
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", 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)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
@@ -101,13 +103,13 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
|
||||
|
||||
err = handler.startStack(stack, endpoint)
|
||||
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
|
||||
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||
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 != "" {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
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)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err}
|
||||
@@ -58,19 +62,17 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
|
||||
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)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
if stack.Status == portainer.StackStatusInactive {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"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}
|
||||
}
|
||||
|
||||
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 {
|
||||
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}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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.AutoUpdate = payload.AutoUpdate
|
||||
stack.Env = payload.Env
|
||||
stack.UpdatedBy = user.Username
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
|
||||
stack.GitConfig.Authentication = nil
|
||||
if payload.RepositoryAuthentication {
|
||||
|
||||
@@ -2,10 +2,8 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
@@ -216,15 +214,15 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, end
|
||||
if stack.Namespace == "" {
|
||||
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 {
|
||||
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{
|
||||
StackID: int(stack.ID),
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "git",
|
||||
_, err = handler.deployKubernetesStack(tokenData.ID, endpoint, stack, k.KubeAppLabels{
|
||||
StackID: int(stack.ID),
|
||||
StackName: stack.Name,
|
||||
Owner: tokenData.Username,
|
||||
Kind: "git",
|
||||
})
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to redeploy Kubernetes stack", Err: errors.WithMessage(err, "failed to deploy kube application")}
|
||||
|
||||
@@ -2,7 +2,10 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
@@ -10,7 +13,9 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
)
|
||||
|
||||
@@ -23,6 +28,7 @@ type kubernetesGitStackUpdatePayload struct {
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
}
|
||||
|
||||
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) {
|
||||
payload.RepositoryReferenceName = defaultGitReferenceName
|
||||
}
|
||||
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
|
||||
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
|
||||
|
||||
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.AutoUpdate = payload.AutoUpdate
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
password := payload.RepositoryPassword
|
||||
if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
|
||||
@@ -61,6 +77,15 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -71,11 +96,27 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
|
||||
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{
|
||||
StackID: int(stack.ID),
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "content",
|
||||
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),
|
||||
StackName: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "content",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -83,7 +124,7 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
|
||||
}
|
||||
|
||||
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 {
|
||||
fileType := "Manifest"
|
||||
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)
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err}
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"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 {
|
||||
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}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,29 +6,37 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/dockercompose"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/agent"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
)
|
||||
|
||||
// ProxyServer provide an extedned proxy with a local server to forward requests
|
||||
// ProxyServer provide an extended proxy with a local server to forward requests
|
||||
type ProxyServer struct {
|
||||
server *http.Server
|
||||
Port int
|
||||
}
|
||||
|
||||
func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) {
|
||||
// NewAgentProxy creates a new instance of ProxyServer that wrap http requests with agent headers
|
||||
func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) {
|
||||
if endpointutils.IsEdgeEndpoint((endpoint)) {
|
||||
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed starting tunnel")
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
|
||||
return &ProxyServer{
|
||||
Port: factory.reverseTunnelService.GetTunnelDetails(endpoint.ID).Port,
|
||||
Port: tunnel.Port,
|
||||
}, nil
|
||||
}
|
||||
|
||||
endpointURL, err := url.Parse(endpoint.URL)
|
||||
endpointURL, err := parseURL(endpoint.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Wrapf(err, "failed parsing url %s", endpoint.URL)
|
||||
}
|
||||
|
||||
endpointURL.Scheme = "http"
|
||||
@@ -37,7 +45,7 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
|
||||
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
|
||||
config, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.WithMessage(err, "failed generating tls configuration")
|
||||
}
|
||||
|
||||
httpTransport.TLSClientConfig = config
|
||||
@@ -46,7 +54,7 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
|
||||
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
|
||||
proxy.Transport = dockercompose.NewAgentTransport(factory.signatureService, httpTransport)
|
||||
proxy.Transport = agent.NewTransport(factory.signatureService, httpTransport)
|
||||
|
||||
proxyServer := &ProxyServer{
|
||||
server: &http.Server{
|
||||
@@ -57,7 +65,7 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
|
||||
|
||||
err = proxyServer.start()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Wrap(err, "failed starting proxy server")
|
||||
}
|
||||
|
||||
return proxyServer, nil
|
||||
@@ -91,3 +99,15 @@ func (proxy *ProxyServer) Close() {
|
||||
proxy.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// parseURL parses the endpointURL using url.Parse.
|
||||
//
|
||||
// to prevent an error when url has port but no protocol prefix
|
||||
// we add `//` prefix if needed
|
||||
func parseURL(endpointURL string) (*url.URL, error) {
|
||||
if !strings.HasPrefix(endpointURL, "http") && !strings.HasPrefix(endpointURL, "tcp") && !strings.HasPrefix(endpointURL, "//") {
|
||||
endpointURL = fmt.Sprintf("//%s", endpointURL)
|
||||
}
|
||||
|
||||
return url.Parse(endpointURL)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package dockercompose
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
// AgentTransport is an http.Transport wrapper that adds custom http headers to communicate to an Agent
|
||||
AgentTransport struct {
|
||||
// Transport is an http.Transport wrapper that adds custom http headers to communicate to an Agent
|
||||
Transport struct {
|
||||
httpTransport *http.Transport
|
||||
signatureService portainer.DigitalSignatureService
|
||||
endpointIdentifier portainer.EndpointID
|
||||
}
|
||||
)
|
||||
|
||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||
func NewAgentTransport(signatureService portainer.DigitalSignatureService, httpTransport *http.Transport) *AgentTransport {
|
||||
transport := &AgentTransport{
|
||||
// NewTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||
func NewTransport(signatureService portainer.DigitalSignatureService, httpTransport *http.Transport) *Transport {
|
||||
transport := &Transport{
|
||||
httpTransport: httpTransport,
|
||||
signatureService: signatureService,
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, httpT
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *AgentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
|
||||
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
@@ -48,10 +48,10 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// CreateComposeProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
|
||||
// CreateAgentProxyServer creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateComposeProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
|
||||
return manager.proxyFactory.NewDockerComposeAgentProxy(endpoint)
|
||||
func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
|
||||
return manager.proxyFactory.NewAgentProxy(endpoint)
|
||||
}
|
||||
|
||||
// GetEndpointProxy returns the proxy associated to a key
|
||||
|
||||
@@ -11,7 +11,12 @@ func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5
|
||||
}
|
||||
|
||||
// IsKubernetesEndpoint returns true if this is a kubernetes environment(endpoint)
|
||||
// IsEdgeEndpoint returns true if this is an edge endpoint
|
||||
func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
|
||||
// IsKubernetesEndpoint returns true if this is a kubernetes endpoint
|
||||
func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.KubernetesLocalEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
|
||||
@@ -2,9 +2,13 @@ package stackutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
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
|
||||
@@ -20,3 +24,39 @@ func GetStackFilePaths(stack *portainer.Stack) []string {
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cmap "github.com/orcaman/concurrent-map"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
@@ -116,36 +115,18 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
|
||||
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
|
||||
|
||||
return factory.createRemoteClient(endpointURL);
|
||||
return factory.createRemoteClient(endpointURL)
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentIdle {
|
||||
err := factory.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed opening tunnel to environment: %w", err)
|
||||
}
|
||||
|
||||
if endpoint.EdgeCheckinInterval == 0 {
|
||||
settings, err := factory.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed fetching settings from db: %w", err)
|
||||
}
|
||||
|
||||
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
waitForAgentToConnect := time.Duration(endpoint.EdgeCheckinInterval) * time.Second
|
||||
time.Sleep(waitForAgentToConnect * 2)
|
||||
|
||||
tunnel = factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
|
||||
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed activating tunnel")
|
||||
}
|
||||
|
||||
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
|
||||
|
||||
return factory.createRemoteClient(endpointURL);
|
||||
return factory.createRemoteClient(endpointURL)
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
)
|
||||
|
||||
type KubeAppLabels struct {
|
||||
StackID int
|
||||
Name string
|
||||
Owner string
|
||||
Kind string
|
||||
StackID int
|
||||
StackName string
|
||||
Owner string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// AddAppLabels adds required labels to "Resource"->metadata->labels.
|
||||
@@ -95,15 +95,9 @@ func addLabels(obj map[string]interface{}, appLabels KubeAppLabels) {
|
||||
}
|
||||
}
|
||||
|
||||
name := appLabels.Name
|
||||
if appLabels.Name == "" {
|
||||
if n, ok := metadata["name"]; ok {
|
||||
name = n.(string)
|
||||
}
|
||||
}
|
||||
|
||||
labels["io.portainer.kubernetes.application.stackid"] = strconv.Itoa(appLabels.StackID)
|
||||
labels["io.portainer.kubernetes.application.name"] = name
|
||||
labels["io.portainer.kubernetes.application.name"] = appLabels.StackName
|
||||
labels["io.portainer.kubernetes.application.stack"] = appLabels.StackName
|
||||
labels["io.portainer.kubernetes.application.owner"] = appLabels.Owner
|
||||
labels["io.portainer.kubernetes.application.kind"] = appLabels.Kind
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
@@ -86,6 +87,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
@@ -174,6 +176,7 @@ items:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
@@ -194,6 +197,7 @@ items:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: redis
|
||||
spec:
|
||||
@@ -216,6 +220,7 @@ items:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
@@ -297,6 +302,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
@@ -322,6 +328,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
@@ -340,6 +347,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
@@ -388,6 +396,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
@@ -402,10 +411,10 @@ spec:
|
||||
}
|
||||
|
||||
labels := KubeAppLabels{
|
||||
StackID: 123,
|
||||
Name: "best-name",
|
||||
Owner: "best-owner",
|
||||
Kind: "git",
|
||||
StackID: 123,
|
||||
StackName: "best-name",
|
||||
Owner: "best-owner",
|
||||
Kind: "git",
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -416,78 +425,3 @@ 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)
|
||||
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)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(result))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package portainer
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
@@ -1276,7 +1275,8 @@ type (
|
||||
|
||||
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1326,6 +1326,7 @@ type (
|
||||
SetTunnelStatusToIdle(endpointID EndpointID)
|
||||
KeepTunnelAlive(endpointID EndpointID, ctx context.Context, maxKeepAlive time.Duration)
|
||||
GetTunnelDetails(endpointID EndpointID) *TunnelDetails
|
||||
GetActiveTunnel(endpoint *Endpoint) (*TunnelDetails, error)
|
||||
AddEdgeJob(endpointID EndpointID, edgeJob *EdgeJob)
|
||||
RemoveEdgeJob(edgeJobID EdgeJobID)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
crontab *cron.Cron
|
||||
shutdownCtx context.Context
|
||||
crontab *cron.Cron
|
||||
activeJobs map[cron.EntryID]context.CancelFunc
|
||||
}
|
||||
|
||||
func NewScheduler(ctx context.Context) *Scheduler {
|
||||
@@ -20,7 +21,8 @@ func NewScheduler(ctx context.Context) *Scheduler {
|
||||
crontab.Start()
|
||||
|
||||
s := &Scheduler{
|
||||
crontab: crontab,
|
||||
crontab: crontab,
|
||||
activeJobs: make(map[cron.EntryID]context.CancelFunc),
|
||||
}
|
||||
|
||||
if ctx != nil {
|
||||
@@ -43,8 +45,10 @@ func (s *Scheduler) Shutdown() error {
|
||||
ctx := s.crontab.Stop()
|
||||
<-ctx.Done()
|
||||
|
||||
for _, j := range s.crontab.Entries() {
|
||||
s.crontab.Remove(j.ID)
|
||||
for _, job := range s.crontab.Entries() {
|
||||
if cancel, ok := s.activeJobs[job.ID]; ok {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
err := ctx.Err()
|
||||
@@ -60,14 +64,36 @@ func (s *Scheduler) StopJob(jobID string) error {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// StartJobEvery schedules a new periodic job with a given duration.
|
||||
// Returns job id that could be used to stop the given job
|
||||
func (s *Scheduler) StartJobEvery(duration time.Duration, job func()) string {
|
||||
entryId := s.crontab.Schedule(cron.Every(duration), cron.FuncJob(job))
|
||||
return strconv.Itoa(int(entryId))
|
||||
// Returns job id that could be used to stop the given job.
|
||||
// When job run returns an error, that job won't be run again.
|
||||
func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
j := cron.FuncJob(func() {
|
||||
if err := job(); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -9,49 +9,92 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_CanStartAndTerminate(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
s.StartJobEvery(1*time.Minute, func() { fmt.Println("boop") })
|
||||
var jobInterval = time.Second
|
||||
|
||||
err := s.Shutdown()
|
||||
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) {
|
||||
func Test_ScheduledJobRuns(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
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
|
||||
jobOne = s.StartJobEvery(time.Second, func() {
|
||||
assert.Equal(t, 1, len(s.crontab.Entries()), "scheduler should have one active job")
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
|
||||
s.StopJob(jobOne)
|
||||
cancel()
|
||||
return nil
|
||||
})
|
||||
|
||||
<-ctx.Done()
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"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 {
|
||||
logger := log.WithFields(log.Fields{"stackID": stackID})
|
||||
logger.Debug("redeploying stack")
|
||||
|
||||
stack, err := datastore.Stack().Stack(stackID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 := "", ""
|
||||
if stack.GitConfig.Authentication != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
author := stack.UpdatedBy
|
||||
if author == "" {
|
||||
author = stack.CreatedBy
|
||||
}
|
||||
|
||||
registries, err := getUserRegistries(datastore, author, endpoint.ID)
|
||||
registries, err := getUserRegistries(datastore, user, endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,6 +95,12 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
if err != nil {
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
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 {
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
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))
|
||||
|
||||
@@ -35,6 +35,10 @@ func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *port
|
||||
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) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
@@ -48,7 +52,11 @@ func Test_redeployWhenChanged_DoesNothingWhenNotAGitBasedStack(t *testing.T) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
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")
|
||||
|
||||
err = RedeployWhenChanged(1, nil, store, &gitService{nil, ""})
|
||||
@@ -61,8 +69,13 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
|
||||
|
||||
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,
|
||||
CreatedBy: "admin",
|
||||
ProjectPath: tmpDir,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "url",
|
||||
@@ -80,8 +93,13 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
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",
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "url",
|
||||
ReferenceName: "ref",
|
||||
@@ -136,12 +154,12 @@ func Test_redeployWhenChanged(t *testing.T) {
|
||||
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
|
||||
store.Stack().UpdateStack(stack.ID, &stack)
|
||||
|
||||
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
|
||||
|
||||
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().CreateUser(&admin)
|
||||
admin := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().CreateUser(admin)
|
||||
assert.NoError(t, err, "error creating an admin")
|
||||
|
||||
user := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
|
||||
err = store.User().CreateUser(&user)
|
||||
user := &portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
|
||||
err = store.User().CreateUser(user)
|
||||
assert.NoError(t, err, "error creating a user")
|
||||
|
||||
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")
|
||||
|
||||
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.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) {
|
||||
registries, err := getUserRegistries(store, user.Username, portainer.EndpointID(endpointID))
|
||||
registries, err := getUserRegistries(store, user, portainer.EndpointID(endpointID))
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam}, registries)
|
||||
})
|
||||
|
||||
@@ -2,27 +2,36 @@ package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
)
|
||||
|
||||
type StackDeployer interface {
|
||||
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) 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 {
|
||||
lock *sync.Mutex
|
||||
swarmStackManager portainer.SwarmStackManager
|
||||
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{
|
||||
lock: &sync.Mutex{},
|
||||
swarmStackManager: swarmStackManager,
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -19,10 +18,9 @@ func StartStackSchedules(scheduler *scheduler.Scheduler, stackdeployer StackDepl
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to parse auto update interval")
|
||||
}
|
||||
jobID := scheduler.StartJobEvery(d, func() {
|
||||
if err := RedeployWhenChanged(stack.ID, stackdeployer, datastore, gitService); err != nil {
|
||||
log.Printf("[ERROR] %s\n", err)
|
||||
}
|
||||
stackID := stack.ID // to be captured by the scheduled function
|
||||
jobID := scheduler.StartJobEvery(d, func() error {
|
||||
return RedeployWhenChanged(stackID, stackdeployer, datastore, gitService)
|
||||
})
|
||||
|
||||
stack.AutoUpdate.JobID = jobID
|
||||
|
||||
@@ -65,7 +65,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<!-- !StaticGroup -->
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<span class="interactive" tooltip-append-to-body="true" tooltip-placement="bottom" tooltip-class="portainer-tooltip" uib-tooltip="Kubeconfig file will {{ $ctrl.state.expiryDays }}">
|
||||
<button
|
||||
ng-if="$ctrl.state.isHTTPS"
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
ng-click="$ctrl.downloadKubeconfig()"
|
||||
analytics-on
|
||||
analytics-category="kubernetes"
|
||||
analytics-event="kubernetes-kubectl-kubeconfig"
|
||||
<span
|
||||
class="interactive"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Kubeconfig file will {{ $ctrl.state.expiryDays }}"
|
||||
>
|
||||
Kubeconfig <i class="fas fa-download space-right"></i>
|
||||
</button>
|
||||
<button
|
||||
ng-if="$ctrl.state.isHTTPS"
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
ng-click="$ctrl.downloadKubeconfig()"
|
||||
analytics-on
|
||||
analytics-category="kubernetes"
|
||||
analytics-event="kubernetes-kubectl-kubeconfig"
|
||||
>
|
||||
Kubeconfig <i class="fas fa-download space-right"></i>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -15,3 +15,8 @@ export const KubernetesDeployRequestMethods = Object.freeze({
|
||||
STRING: 'string',
|
||||
URL: 'url',
|
||||
});
|
||||
|
||||
export const RepositoryMechanismTypes = Object.freeze({
|
||||
WEBHOOK: 'Webhook',
|
||||
INTERVAL: 'Interval',
|
||||
});
|
||||
|
||||
@@ -102,7 +102,8 @@ class KubernetesServiceService {
|
||||
const namespace = service.Namespace;
|
||||
await this.KubernetesServices(namespace).delete(params).$promise;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to remove service', err);
|
||||
// ignoring error on deletion
|
||||
// throw new PortainerError('Unable to remove service', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models
|
||||
|
||||
class KubernetesApplicationsController {
|
||||
/* @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.$state = $state;
|
||||
this.Notifications = Notifications;
|
||||
@@ -17,6 +17,7 @@ class KubernetesApplicationsController {
|
||||
this.Authentication = Authentication;
|
||||
this.ModalService = ModalService;
|
||||
this.LocalStorage = LocalStorage;
|
||||
this.StackService = StackService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getApplications = this.getApplications.bind(this);
|
||||
@@ -36,8 +37,18 @@ class KubernetesApplicationsController {
|
||||
let actionCount = selectedItems.length;
|
||||
for (const stack of selectedItems) {
|
||||
try {
|
||||
const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app));
|
||||
await Promise.all(promises);
|
||||
const isAppFormCreated = stack.Applications.some((x) => !x.ApplicationKind);
|
||||
|
||||
if (isAppFormCreated) {
|
||||
const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app));
|
||||
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);
|
||||
_.remove(this.state.stacks, { Name: stack.Name });
|
||||
} catch (err) {
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
class-name="text-muted"
|
||||
url="ctrl.stack.GitConfig.URL"
|
||||
config-file-path="ctrl.stack.GitConfig.ConfigFilePath"
|
||||
additional-files="ctrl.stack.AdditionalFiles"
|
||||
type="application"
|
||||
></git-form-info-panel>
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
|
||||
Namespace
|
||||
@@ -55,11 +57,11 @@
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region Git repository -->
|
||||
<kubernetes-app-git-form
|
||||
<kubernetes-redeploy-app-git-form
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT"
|
||||
stack="ctrl.stack"
|
||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||
></kubernetes-app-git-form>
|
||||
></kubernetes-redeploy-app-git-form>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region web editor -->
|
||||
@@ -190,7 +192,12 @@
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Environment variables</label>
|
||||
<span ng-if="ctrl.formValues.Containers.length <= 1" class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addEnvironmentVariable()">
|
||||
<span
|
||||
ng-if="ctrl.formValues.Containers.length <= 1"
|
||||
class="label label-default interactive"
|
||||
style="margin-left: 10px;"
|
||||
ng-click="ctrl.addEnvironmentVariable()"
|
||||
>
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
|
||||
</span>
|
||||
</div>
|
||||
@@ -283,8 +290,8 @@
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted" style="margin-top: 15px;" ng-if="ctrl.formValues.Configurations.length">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Portainer will automatically expose all the keys of a configuration as environment variables. This behavior can be overriden to filesystem mounts for each key via
|
||||
the override button.
|
||||
Portainer will automatically expose all the keys of a configuration as environment variables. This behavior can be overriden to filesystem mounts for each key
|
||||
via the override button.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -544,7 +551,9 @@
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-show="kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$invalid || ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined"
|
||||
ng-show="
|
||||
kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$invalid || ctrl.state.duplicates.persistedFolders.refs[$index] !== undefined
|
||||
"
|
||||
>
|
||||
<ng-messages for="kubernetesApplicationCreationForm['persisted_folder_path_' + $index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Path is required.</p>
|
||||
@@ -623,7 +632,9 @@
|
||||
</div>
|
||||
<div
|
||||
style="color: #767676;"
|
||||
ng-if="(ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED) || ctrl.state.persistedFoldersUseExistingVolumes"
|
||||
ng-if="
|
||||
(ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED) || ctrl.state.persistedFoldersUseExistingVolumes
|
||||
"
|
||||
>
|
||||
<input type="radio" id="data_access_isolated" disabled />
|
||||
<label
|
||||
@@ -694,8 +705,8 @@
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && !ctrl.resourceQuotaCapacityExceeded()">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
A resource quota is set on this namespace, you must specify resource reservations. Resource reservations are applied per instance of the application. Maximums are
|
||||
inherited from the namespace quota.
|
||||
A resource quota is set on this namespace, you must specify resource reservations. Resource reservations are applied per instance of the application. Maximums
|
||||
are inherited from the namespace quota.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -991,7 +1002,15 @@
|
||||
</td>
|
||||
<td style="padding: 8px 5px 5px 0; border: none;">
|
||||
<div class="input-group input-group-sm" style="width: 100%;">
|
||||
<input type="number" class="form-control" name="auto_scaler_cpu" ng-model="ctrl.formValues.AutoScaler.TargetCPUUtilization" min="1" max="100" required />
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="auto_scaler_cpu"
|
||||
ng-model="ctrl.formValues.AutoScaler.TargetCPUUtilization"
|
||||
min="1"
|
||||
max="100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group input-group-sm" ng-show="kubernetesApplicationCreationForm['auto_scaler_cpu'].$invalid">
|
||||
<div class="small text-warning" style="margin-top: 5px;">
|
||||
@@ -1145,7 +1164,8 @@
|
||||
<label
|
||||
for="publishing_internal"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() || (ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INTERNAL)
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INTERNAL)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
@@ -1183,7 +1203,8 @@
|
||||
<label
|
||||
for="publishing_cluster"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() || (ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER)
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.CLUSTER)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
@@ -1220,7 +1241,8 @@
|
||||
<label
|
||||
for="publishing_ingress"
|
||||
ng-if="
|
||||
!ctrl.isPublishingTypeEditDisabled() || (ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
!ctrl.isPublishingTypeEditDisabled() ||
|
||||
(ctrl.isPublishingTypeEditDisabled() && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
@@ -1564,8 +1586,8 @@
|
||||
<div ng-messages="kubernetesApplicationCreationForm['ingress_route_'+$index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Route is required.</p>
|
||||
<p ng-message="pattern"
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field must consist of alphanumeric characters or the special characters: '-', '_' or
|
||||
'/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').</p
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field must consist of alphanumeric characters or the special characters: '-', '_'
|
||||
or '/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.publishedPorts.ingressRoutes.refs[$index] !== undefined">
|
||||
@@ -1608,7 +1630,7 @@
|
||||
form-values="ctrl.formValues"
|
||||
old-form-values="ctrl.savedFormValues"
|
||||
></kubernetes-summary-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT">
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
</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">
|
||||
Build method
|
||||
</div>
|
||||
@@ -42,32 +49,16 @@
|
||||
</div>
|
||||
|
||||
<!-- repository -->
|
||||
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.GIT">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Git repository
|
||||
</div>
|
||||
<git-form-url-field value="ctrl.formValues.RepositoryURL" on-change="(ctrl.onRepoUrlChange)"></git-form-url-field>
|
||||
<git-form-ref-field value="ctrl.formValues.RepositoryReferenceName" on-change="(ctrl.onRepoRefChange)"></git-form-ref-field>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the yaml file from the root of your repository.
|
||||
</span>
|
||||
</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>
|
||||
<git-form
|
||||
ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.GIT"
|
||||
model="ctrl.formValues"
|
||||
on-change="(ctrl.onChangeFormValues)"
|
||||
additional-file="true"
|
||||
auto-update="true"
|
||||
show-auth-explanation="true"
|
||||
path-text-title="Manifest path"
|
||||
path-placeholder="deployment.yml"
|
||||
></git-form>
|
||||
<!-- !repository -->
|
||||
|
||||
<custom-template-selector
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import uuidv4 from 'uuid/v4';
|
||||
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';
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $window, Authentication, CustomTemplateService, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) {
|
||||
constructor($async, $state, $window, Authentication, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService, WebhookHelper) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
this.Authentication = Authentication;
|
||||
this.CustomTemplateService = CustomTemplateService;
|
||||
this.ModalService = ModalService;
|
||||
this.Notifications = Notifications;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.StackService = StackService;
|
||||
this.WebhookHelper = WebhookHelper;
|
||||
|
||||
this.deployOptions = [
|
||||
buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES),
|
||||
@@ -41,7 +42,20 @@ class KubernetesDeployController {
|
||||
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.BuildMethods = KubernetesDeployBuildMethods;
|
||||
this.endpointId = this.EndpointProvider.endpointID();
|
||||
@@ -51,8 +65,6 @@ class KubernetesDeployController {
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.getNamespacesAsync = this.getNamespacesAsync.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);
|
||||
}
|
||||
|
||||
@@ -61,6 +73,7 @@ class KubernetesDeployController {
|
||||
type: buildLabel(this.state.BuildMethod),
|
||||
format: formatLabel(this.state.DeployType),
|
||||
role: roleLabel(this.Authentication.isAdmin()),
|
||||
'automatic-updates': automaticUpdatesLabel(this.formValues.RepositoryAutomaticUpdates, this.formValues.RepositoryMechanism),
|
||||
};
|
||||
|
||||
if (this.state.BuildMethod === KubernetesDeployBuildMethods.GIT) {
|
||||
@@ -69,6 +82,17 @@ class KubernetesDeployController {
|
||||
|
||||
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) {
|
||||
if (isAdmin) {
|
||||
return 'admin';
|
||||
@@ -99,17 +123,14 @@ class KubernetesDeployController {
|
||||
disableDeploy() {
|
||||
const isGitFormInvalid =
|
||||
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
|
||||
(!this.formValues.RepositoryURL ||
|
||||
!this.formValues.FilePathInRepository ||
|
||||
(this.formValues.RepositoryAuthentication && (!this.formValues.RepositoryUsername || !this.formValues.RepositoryPassword))) &&
|
||||
(!this.formValues.RepositoryURL || !this.formValues.FilePathInRepository || (this.formValues.RepositoryAuthentication && !this.formValues.RepositoryPassword)) &&
|
||||
_.isEmpty(this.formValues.Namespace);
|
||||
const isWebEditorInvalid =
|
||||
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 isNamespaceInvalid = _.isEmpty(this.formValues.Namespace);
|
||||
|
||||
return isGitFormInvalid || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid;
|
||||
return !this.formValues.StackName || isGitFormInvalid || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid;
|
||||
}
|
||||
|
||||
onChangeFormValues(values) {
|
||||
@@ -119,14 +140,6 @@ class KubernetesDeployController {
|
||||
};
|
||||
}
|
||||
|
||||
onRepoUrlChange(value) {
|
||||
this.onChangeFormValues({ RepositoryURL: value });
|
||||
}
|
||||
|
||||
onRepoRefChange(value) {
|
||||
this.onChangeFormValues({ RepositoryReferenceName: value });
|
||||
}
|
||||
|
||||
onChangeTemplateId(templateId) {
|
||||
return this.$async(async () => {
|
||||
if (this.state.templateId === templateId) {
|
||||
@@ -184,6 +197,7 @@ class KubernetesDeployController {
|
||||
const payload = {
|
||||
ComposeFormat: composeFormat,
|
||||
Namespace: this.formValues.Namespace,
|
||||
StackName: this.formValues.StackName,
|
||||
};
|
||||
|
||||
if (method === KubernetesDeployRequestMethods.REPOSITORY) {
|
||||
@@ -194,7 +208,16 @@ class KubernetesDeployController {
|
||||
payload.RepositoryUsername = this.formValues.RepositoryUsername;
|
||||
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) {
|
||||
payload.StackFileContent = this.formValues.EditorContent;
|
||||
} else {
|
||||
|
||||
@@ -213,8 +213,8 @@
|
||||
<div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length === 0">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
The ingress feature must be enabled in the
|
||||
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: $ctrl.endpoint.Id})">environment configuration view</a> to be able to register ingresses inside this
|
||||
namespace.
|
||||
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: $ctrl.endpoint.Id})">environment configuration view</a> to be able to register ingresses inside
|
||||
this namespace.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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
|
||||
other.
|
||||
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 other.
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 20px;">
|
||||
<!-- available-endpoints -->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -15,8 +15,10 @@
|
||||
</div>
|
||||
<div ng-if="$ctrl.model.RepositoryAuthentication">
|
||||
<div class="form-group">
|
||||
<label for="repository_username" class="col-sm-2 control-label text-left">Username</label>
|
||||
<div class="col-sm-3">
|
||||
<label for="repository_username" class="control-label text-left inline-label">
|
||||
Username
|
||||
</label>
|
||||
<div class="inline-input">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
@@ -29,11 +31,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
<portainer-tooltip position="bottom" message="Provide a personal access token or password"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-3">
|
||||
<div class="inline-input">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import controller from './git-form-auth-fieldset.controller.js';
|
||||
import './git-form-auth-fieldset.css';
|
||||
|
||||
export const gitFormAuthFieldset = {
|
||||
templateUrl: './git-form-auth-fieldset.html',
|
||||
|
||||
@@ -2,7 +2,6 @@ export const gitFormComposePathField = {
|
||||
templateUrl: './git-form-compose-path-field.html',
|
||||
bindings: {
|
||||
deployMethod: '@',
|
||||
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<div class="form-group" ng-class="$ctrl.className">
|
||||
<div class="col-sm-12">
|
||||
<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>
|
||||
Update
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,5 +5,6 @@ export const gitFormInfoPanel = {
|
||||
configFilePath: '<',
|
||||
additionalFiles: '<',
|
||||
className: '@',
|
||||
type: '@',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 other.
|
||||
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 other.
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 20px;">
|
||||
<!-- available-endpoints -->
|
||||
|
||||
@@ -83,7 +83,6 @@ class KubernetesAppGitFormController {
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
console.log(this);
|
||||
this.formValues.RefName = this.stack.GitConfig.ReferenceName;
|
||||
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
|
||||
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuidv4 from 'uuid/v4';
|
||||
|
||||
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
class StackRedeployGitFormController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, StackService, ModalService, Notifications, WebhookHelper, FormHelper) {
|
||||
@@ -28,7 +28,7 @@ class StackRedeployGitFormController {
|
||||
// auto update
|
||||
AutoUpdate: {
|
||||
RepositoryAutomaticUpdates: false,
|
||||
RepositoryMechanism: 'Interval',
|
||||
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
|
||||
RepositoryFetchInterval: '5m',
|
||||
RepositoryWebhookURL: '',
|
||||
},
|
||||
@@ -50,9 +50,9 @@ class StackRedeployGitFormController {
|
||||
|
||||
function autoSyncLabel(type) {
|
||||
switch (type) {
|
||||
case 'Interval':
|
||||
case RepositoryMechanismTypes.INTERVAL:
|
||||
return 'polling';
|
||||
case 'Webhook':
|
||||
case RepositoryMechanismTypes.WEBHOOK:
|
||||
return 'webhook';
|
||||
}
|
||||
return 'off';
|
||||
@@ -156,10 +156,10 @@ class StackRedeployGitFormController {
|
||||
this.formValues.AutoUpdate.RepositoryAutomaticUpdates = true;
|
||||
|
||||
if (this.stack.AutoUpdate.Interval) {
|
||||
this.formValues.AutoUpdate.RepositoryMechanism = `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 = `Webhook`;
|
||||
this.formValues.AutoUpdate.RepositoryMechanism = RepositoryMechanismTypes.WEBHOOK;
|
||||
this.formValues.AutoUpdate.RepositoryWebhookURL = this.WebhookHelper.returnStackWebhookUrl(this.stack.AutoUpdate.Webhook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { StackViewModel, OrphanedStackViewModel } from '../../models/stack';
|
||||
|
||||
angular.module('portainer.app').factory('StackService', [
|
||||
@@ -277,7 +278,17 @@ angular.module('portainer.app').factory('StackService', [
|
||||
StackFileContent: stackFile,
|
||||
};
|
||||
} 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 = {
|
||||
AutoUpdate: autoUpdate,
|
||||
RepositoryReferenceName: gitConfig.RefName,
|
||||
RepositoryAuthentication: gitConfig.RepositoryAuthentication,
|
||||
RepositoryUsername: gitConfig.RepositoryUsername,
|
||||
@@ -455,9 +466,9 @@ angular.module('portainer.app').factory('StackService', [
|
||||
const autoUpdate = {};
|
||||
|
||||
if (gitConfig.AutoUpdate.RepositoryAutomaticUpdates) {
|
||||
if (gitConfig.AutoUpdate.RepositoryMechanism === 'Interval') {
|
||||
if (gitConfig.AutoUpdate.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class CreateCustomTemplateViewController {
|
||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
Description: '',
|
||||
Note: '',
|
||||
Logo:'',
|
||||
Logo: '',
|
||||
Platform: 1,
|
||||
Type: 1,
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
|
||||
<div class="row">
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import angular from 'angular';
|
||||
import uuidv4 from 'uuid/v4';
|
||||
|
||||
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel';
|
||||
|
||||
angular
|
||||
@@ -42,7 +42,7 @@ angular
|
||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
RepositoryAutomaticUpdates: true,
|
||||
RepositoryMechanism: 'Interval',
|
||||
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
|
||||
RepositoryFetchInterval: '5m',
|
||||
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
|
||||
};
|
||||
@@ -111,9 +111,9 @@ angular
|
||||
|
||||
function autoSyncLabel(type) {
|
||||
switch (type) {
|
||||
case 'Interval':
|
||||
case RepositoryMechanismTypes.INTERVAL:
|
||||
return 'polling';
|
||||
case 'Webhook':
|
||||
case RepositoryMechanismTypes.WEBHOOK:
|
||||
return 'webhook';
|
||||
}
|
||||
return 'off';
|
||||
@@ -166,9 +166,9 @@ angular
|
||||
function getAutoUpdatesProperty(repositoryOptions) {
|
||||
if ($scope.formValues.RepositoryAutomaticUpdates) {
|
||||
repositoryOptions.AutoUpdate = {};
|
||||
if ($scope.formValues.RepositoryMechanism === 'Interval') {
|
||||
if ($scope.formValues.RepositoryMechanism === RepositoryMechanismTypes.INTERVAL) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@
|
||||
additional-file="true"
|
||||
auto-update="true"
|
||||
show-auth-explanation="true"
|
||||
path-text-title="Compose path"
|
||||
path-placeholder="docker-compose.yml"
|
||||
></git-form>
|
||||
|
||||
<custom-template-selector
|
||||
|
||||
Reference in New Issue
Block a user