1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

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

This commit is contained in:
Dmitry Salakhov 2021-08-31 13:41:19 +12:00
parent a5058e8f1e
commit 0e60f40937
22 changed files with 450 additions and 364 deletions

View file

@ -56,6 +56,34 @@ func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *porta
} }
} }
func (service *Service) GetActiveTunnel(endpoint *portainer.Endpoint) (*portainer.TunnelDetails, error) {
tunnel := service.GetTunnelDetails(endpoint.ID)
if tunnel.Status != portainer.EdgeAgentIdle {
return tunnel, nil
}
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. // SetTunnelStatusToActive update the status of the tunnel associated to the specified endpoint.
// It sets the status to ACTIVE. // It sets the status to ACTIVE.
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) { func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {

View file

@ -100,8 +100,8 @@ func initSwarmStackManager(assetsPath string, dataStorePath string, signatureSer
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService) return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
} }
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer { 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, assetsPath) return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
} }
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) { func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
@ -455,7 +455,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager) composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
if dataStore.IsNew() { if dataStore.IsNew() {
err = updateSettingsFromFlags(dataStore, flags) err = updateSettingsFromFlags(dataStore, flags)
@ -523,7 +523,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
} }
scheduler := scheduler.NewScheduler(shutdownCtx) scheduler := scheduler.NewScheduler(shutdownCtx)
stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager) stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer)
stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService) stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
return &http.Server{ return &http.Server{

View file

@ -44,7 +44,7 @@ func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string {
func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
url, proxy, err := w.fetchEndpointProxy(endpoint) url, proxy, err := w.fetchEndpointProxy(endpoint)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to featch endpoint proxy") return errors.Wrap(err, "failed to fetch endpoint proxy")
} }
if proxy != nil { if proxy != nil {
@ -88,7 +88,7 @@ func (w *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (
return "", nil, nil return "", nil, nil
} }
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint) proxy, err := w.proxyManager.CreateAgentProxyServer(endpoint)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }

View file

@ -2,24 +2,22 @@ package exec
import ( import (
"bytes" "bytes"
"encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"net/url"
"os/exec" "os/exec"
"path" "path"
"runtime" "runtime"
"strings" "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/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/kubernetes/cli"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
) )
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment. // KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment.
@ -30,10 +28,11 @@ type KubernetesDeployer struct {
signatureService portainer.DigitalSignatureService signatureService portainer.DigitalSignatureService
kubernetesClientFactory *cli.ClientFactory kubernetesClientFactory *cli.ClientFactory
kubernetesTokenCacheManager *kubernetes.TokenCacheManager kubernetesTokenCacheManager *kubernetes.TokenCacheManager
proxyManager *proxy.Manager
} }
// NewKubernetesDeployer initializes a new KubernetesDeployer service. // 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{ return &KubernetesDeployer{
binaryPath: binaryPath, binaryPath: binaryPath,
dataStore: datastore, dataStore: datastore,
@ -41,23 +40,28 @@ func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheMan
signatureService: signatureService, signatureService: signatureService,
kubernetesClientFactory: kubernetesClientFactory, kubernetesClientFactory: kubernetesClientFactory,
kubernetesTokenCacheManager: kubernetesTokenCacheManager, kubernetesTokenCacheManager: kubernetesTokenCacheManager,
proxyManager: proxyManager,
} }
} }
func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) { func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool, getAdminToken bool) (string, error) {
tokenData, err := security.RetrieveTokenData(request) kubeCLI, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return "", err
}
kubecli, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil { if err != nil {
return "", err return "", err
} }
tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID)) 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 getAdminToken {
return tokenManager.GetAdminServiceAccountToken(), nil
}
tokenData, err := security.RetrieveTokenData(request)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -79,154 +83,61 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po
// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint. // Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint.
// Otherwise it will use kubectl to deploy the manifest. // 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) { func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, manifestFiles []string, namespace string, deployAsAdmin bool) (string, error) {
if endpoint.Type == portainer.KubernetesLocalEnvironment { token, err := deployer.getToken(request, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment, deployAsAdmin)
token, err := deployer.getToken(request, endpoint, true) if err != nil {
if err != nil { return "", err
return "", err }
command := path.Join(deployer.binaryPath, "kubectl")
if runtime.GOOS == "windows" {
command = path.Join(deployer.binaryPath, "kubectl.exe")
}
args := make([]string, 0)
if endpoint.Type != portainer.KubernetesLocalEnvironment {
url := endpoint.URL
switch endpoint.Type {
case portainer.AgentOnKubernetesEnvironment:
agentUrl, agentProxy, err := deployer.getAgentURL(endpoint)
if err != nil {
return "", errors.WithMessage(err, "failed generating endpoint URL")
}
url = agentUrl
defer agentProxy.Close()
case portainer.EdgeAgentOnKubernetesEnvironment:
url, err = deployer.getEdgeUrl(endpoint)
if err != nil {
return "", errors.WithMessage(err, "failed generating endpoint URL")
}
} }
command := path.Join(deployer.binaryPath, "kubectl") args = append(args, "--server", url)
if runtime.GOOS == "windows" {
command = path.Join(deployer.binaryPath, "kubectl.exe")
}
args := make([]string, 0)
args = append(args, "--server", endpoint.URL)
args = append(args, "--insecure-skip-tls-verify") 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 args = append(args, "--token", token)
args = append(args, "--namespace", namespace)
endpointURL := endpoint.URL var fileArgs []string
if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { for _, path := range manifestFiles {
tunnel := deployer.reverseTunnelService.GetTunnelDetails(endpoint.ID) fileArgs = append(fileArgs, "-f")
if tunnel.Status == portainer.EdgeAgentIdle { fileArgs = append(fileArgs, strings.TrimSpace(path))
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)
} }
args = append(args, "apply")
args = append(args, fileArgs...)
transport := &http.Transport{} var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
if endpoint.TLSConfig.TLS { output, err := cmd.Output()
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return "", err
}
transport.TLSClientConfig = tlsConfig
}
httpCli := &http.Client{
Transport: transport,
}
if !strings.HasPrefix(endpointURL, "http") {
endpointURL = fmt.Sprintf("https://%s", endpointURL)
}
url, err := url.Parse(fmt.Sprintf("%s/v2/kubernetes/stack", endpointURL))
if err != nil { if err != nil {
return "", err return "", errors.Wrapf(err, "failed to execute kubectl command: %q", stderr.String())
} }
reqPayload, err := json.Marshal( return string(output), nil
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
} }
// ConvertCompose leverages the kompose binary to deploy a compose compliant manifest. // ConvertCompose leverages the kompose binary to deploy a compose compliant manifest.
@ -251,3 +162,21 @@ func (deployer *KubernetesDeployer) ConvertCompose(data []byte) ([]byte, error)
return output, nil return output, nil
} }
func (deployer *KubernetesDeployer) getEdgeUrl(endpoint *portainer.Endpoint) (string, error) {
tunnel, err := deployer.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return "", errors.Wrap(err, "failed activating tunnel")
}
return fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port), 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
View file

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

View file

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

View file

@ -1,9 +1,9 @@
package stacks package stacks
import ( import (
"io/ioutil" "fmt"
"net/http" "net/http"
"path/filepath" "os"
"strconv" "strconv"
"time" "time"
@ -17,6 +17,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/internal/stackutils"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
) )
@ -34,7 +35,9 @@ type kubernetesGitDeploymentPayload struct {
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
FilePathInRepository string ManifestFile string
AdditionalFiles []string
AutoUpdate *portainer.StackAutoUpdate
} }
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error { func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
@ -57,12 +60,15 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
} }
if govalidator.IsNull(payload.FilePathInRepository) { if govalidator.IsNull(payload.ManifestFile) {
return errors.New("Invalid file path in repository") return errors.New("Invalid manifest file in repository")
} }
if govalidator.IsNull(payload.RepositoryReferenceName) { if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName payload.RepositoryReferenceName = defaultGitReferenceName
} }
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}
return nil return nil
} }
@ -104,7 +110,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
doCleanUp := true doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp) defer handler.cleanUp(stack, &doCleanUp)
output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ output, err := handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{
StackID: stackID, StackID: stackID,
Name: stack.Name, Name: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
@ -140,22 +146,34 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
} }
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err}
}
if !isUnique {
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists}
}
}
stackID := handler.DataStore.Stack().GetNextIdentifier() stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{ stack := &portainer.Stack{
ID: portainer.StackID(stackID), ID: portainer.StackID(stackID),
Type: portainer.KubernetesStack, Type: portainer.KubernetesStack,
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
EntryPoint: payload.FilePathInRepository, EntryPoint: payload.ManifestFile,
GitConfig: &gittypes.RepoConfig{ GitConfig: &gittypes.RepoConfig{
URL: payload.RepositoryURL, URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName, ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository, ConfigFilePath: payload.ManifestFile,
}, },
Namespace: payload.Namespace, Namespace: payload.Namespace,
Status: portainer.StackStatusActive, Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
CreatedBy: user.Username, CreatedBy: user.Username,
IsComposeFormat: payload.ComposeFormat, IsComposeFormat: payload.ComposeFormat,
AutoUpdate: payload.AutoUpdate,
} }
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
@ -170,12 +188,19 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
} }
stack.GitConfig.ConfigHash = commitId stack.GitConfig.ConfigHash = commitId
stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath) repositoryUsername := payload.RepositoryUsername
if err != nil { repositoryPassword := payload.RepositoryPassword
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err} if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
} }
output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to clone git repository", Err: err}
}
output, err := handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{
StackID: stackID, StackID: stackID,
Name: stack.Name, Name: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
@ -186,6 +211,15 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
} }
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
if e != nil {
return e
}
stack.AutoUpdate.JobID = jobID
}
err = handler.DataStore.Stack().CreateStack(stack) err = handler.DataStore.Stack().CreateStack(stack)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
@ -199,43 +233,14 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
return response.JSON(w, resp) return response.JSON(w, resp)
} }
func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) { func (handler *Handler) deployKubernetesStack(r *http.Request, endpoint *portainer.Endpoint, stack *portainer.Stack, appLabels k.KubeAppLabels) (string, error) {
handler.stackCreationMutex.Lock() handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock() defer handler.stackCreationMutex.Unlock()
manifest := []byte(stackConfig) manifestFilePaths, tempDir, err := stackutils.CreateTempK8SDeploymentFiles(stack, handler.KubernetesDeployer, appLabels)
if composeFormat {
convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(manifest)
if err != nil {
return "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest")
}
manifest = convertedConfig
}
manifest, err := k.AddAppLabels(manifest, appLabels)
if err != nil { if err != nil {
return "", errors.Wrap(err, "failed to add application labels") return "", errors.Wrap(err, "failed to create temp kub deployment files")
} }
defer os.RemoveAll(tempDir)
return handler.KubernetesDeployer.Deploy(request, endpoint, string(manifest), namespace) return handler.KubernetesDeployer.Deploy(r, endpoint, manifestFilePaths, stack.Namespace, false)
}
func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {
repositoryUsername := gitInfo.RepositoryUsername
repositoryPassword := gitInfo.RepositoryPassword
if !gitInfo.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err := handler.GitService.CloneRepository(projectPath, gitInfo.RepositoryURL, gitInfo.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return "", err
}
content, err := ioutil.ReadFile(filepath.Join(projectPath, gitInfo.FilePathInRepository))
if err != nil {
return "", err
}
return string(content), nil
} }

View file

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

View file

@ -2,6 +2,7 @@ package stacks
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -33,12 +34,12 @@ import (
func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveRouteVariableValue(r, "id") stackID, err := request.RetrieveRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
} }
externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true) externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true)
@ -48,51 +49,51 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
id, err := strconv.Atoi(stackID) id, err := strconv.Atoi(stackID)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
} }
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(id)) stack, err := handler.DataStore.Stack().Stack(portainer.StackID(id))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} }
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
} }
isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID
if isOrphaned && !securityContext.IsAdmin { if isOrphaned && !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to remove orphaned stack", Err: errors.New("Permission denied to remove orphaned stack")}
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} }
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
} }
if !isOrphaned { if !isOrphaned {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
} }
} }
@ -103,24 +104,24 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
err = handler.deleteStack(stack, endpoint) err = handler.deleteStack(stack, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
err = handler.DataStore.Stack().DeleteStack(portainer.StackID(id)) err = handler.DataStore.Stack().DeleteStack(portainer.StackID(id))
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to remove the stack from the database", Err: err}
} }
if resourceControl != nil { if resourceControl != nil {
err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID) err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the associated resource control from the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to remove the associated resource control from the database", Err: err}
} }
} }
err = handler.FileService.RemoveDirectory(stack.ProjectPath) err = handler.FileService.RemoveDirectory(stack.ProjectPath)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove stack files from disk", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to remove stack files from disk", Err: err}
} }
return response.Empty(w) return response.Empty(w)
@ -129,31 +130,31 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string, securityContext *security.RestrictedRequestContext) *httperror.HandlerError { func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string, securityContext *security.RestrictedRequestContext) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
} }
if !securityContext.IsAdmin { if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusUnauthorized, "Permission denied to delete the stack", httperrors.ErrUnauthorized} return &httperror.HandlerError{StatusCode: http.StatusUnauthorized, Message: "Permission denied to delete the stack", Err: httperrors.ErrUnauthorized}
} }
stack, err := handler.DataStore.Stack().StackByName(stackName) stack, err := handler.DataStore.Stack().StackByName(stackName)
if err != nil && err != bolterrors.ErrObjectNotFound { if err != nil && err != bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for stack existence inside the database", Err: err}
} }
if stack != nil { if stack != nil {
return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", errors.New("A tag already exists with this name")} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "A stack with this name exists inside the database. Cannot use external delete method", Err: errors.New("A tag already exists with this name")}
} }
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} }
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
} }
stack = &portainer.Stack{ stack = &portainer.Stack{
@ -163,7 +164,7 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit
err = handler.deleteStack(stack, endpoint) err = handler.deleteStack(stack, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete stack", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to delete stack", Err: err}
} }
return response.Empty(w) return response.Empty(w)
@ -173,6 +174,11 @@ func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer.
if stack.Type == portainer.DockerSwarmStack { if stack.Type == portainer.DockerSwarmStack {
return handler.SwarmStackManager.Remove(stack, endpoint) return handler.SwarmStackManager.Remove(stack, endpoint)
} }
if stack.Type == portainer.DockerComposeStack {
return handler.ComposeStackManager.Down(stack, endpoint) return handler.ComposeStackManager.Down(stack, endpoint)
}
if stack.Type == portainer.KubernetesStack {
return nil
}
return fmt.Errorf("Unsupported stack type: %v", stack.Type)
} }

View file

@ -2,10 +2,8 @@ package stacks
import ( import (
"fmt" "fmt"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"path/filepath"
"time" "time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
@ -216,11 +214,7 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, end
if stack.Namespace == "" { if stack.Namespace == "" {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Invalid namespace", Err: errors.New("Namespace must not be empty when redeploying kubernetes stacks")} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Invalid namespace", Err: errors.New("Namespace must not be empty when redeploying kubernetes stacks")}
} }
content, err := ioutil.ReadFile(filepath.Join(stack.ProjectPath, stack.GitConfig.ConfigFilePath)) _, err := handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{
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")}
}
_, err = handler.deployKubernetesStack(r, endpoint, string(content), stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{
StackID: int(stack.ID), StackID: int(stack.ID),
Name: stack.Name, Name: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,

View file

@ -22,6 +22,7 @@ type kubernetesGitStackUpdatePayload struct {
RepositoryAuthentication bool RepositoryAuthentication bool
RepositoryUsername string RepositoryUsername string
RepositoryPassword string RepositoryPassword string
AutoUpdate *portainer.StackAutoUpdate
} }
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
@ -35,12 +36,20 @@ func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error
if govalidator.IsNull(payload.RepositoryReferenceName) { if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName payload.RepositoryReferenceName = defaultGitReferenceName
} }
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}
return nil return nil
} }
func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
if stack.GitConfig != nil { if stack.GitConfig != nil {
//stop the autoupdate job if there is any
if stack.AutoUpdate != nil {
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
}
var payload kubernetesGitStackUpdatePayload var payload kubernetesGitStackUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
@ -48,6 +57,8 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
} }
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.AutoUpdate = payload.AutoUpdate
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {
password := payload.RepositoryPassword password := payload.RepositoryPassword
if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
@ -60,6 +71,15 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
} else { } else {
stack.GitConfig.Authentication = nil stack.GitConfig.Authentication = nil
} }
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
if e != nil {
return e
}
stack.AutoUpdate.JobID = jobID
}
return nil return nil
} }
@ -70,7 +90,14 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
_, err = handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{ stackFolder := strconv.Itoa(int(stack.ID))
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
}
stack.ProjectPath = projectPath
_, err = handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{
StackID: int(stack.ID), StackID: int(stack.ID),
Name: stack.Name, Name: stack.Name,
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
@ -81,11 +108,5 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack via file content", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack via file content", Err: err}
} }
stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
}
return nil return nil
} }

View file

@ -6,29 +6,37 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto" "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 { type ProxyServer struct {
server *http.Server server *http.Server
Port int 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{ return &ProxyServer{
Port: factory.reverseTunnelService.GetTunnelDetails(endpoint.ID).Port, Port: tunnel.Port,
}, nil }, nil
} }
endpointURL, err := url.Parse(endpoint.URL) endpointURL, err := parseURL(endpoint.URL)
if err != nil { if err != nil {
return nil, err return nil, errors.Wrapf(err, "failed parsing url %s", endpoint.URL)
} }
endpointURL.Scheme = "http" endpointURL.Scheme = "http"
@ -37,7 +45,7 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
config, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) config, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil { if err != nil {
return nil, err return nil, errors.WithMessage(err, "failed generating tls configuration")
} }
httpTransport.TLSClientConfig = config httpTransport.TLSClientConfig = config
@ -46,7 +54,7 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL) proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = dockercompose.NewAgentTransport(factory.signatureService, httpTransport) proxy.Transport = agent.NewTransport(factory.signatureService, httpTransport)
proxyServer := &ProxyServer{ proxyServer := &ProxyServer{
server: &http.Server{ server: &http.Server{
@ -57,7 +65,7 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
err = proxyServer.start() err = proxyServer.start()
if err != nil { if err != nil {
return nil, err return nil, errors.Wrap(err, "failed starting proxy server")
} }
return proxyServer, err return proxyServer, err
@ -91,3 +99,15 @@ func (proxy *ProxyServer) Close() {
proxy.server.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)
}

View file

@ -1,4 +1,4 @@
package dockercompose package agent
import ( import (
"net/http" "net/http"
@ -7,17 +7,17 @@ import (
) )
type ( type (
// AgentTransport is an http.Transport wrapper that adds custom http headers to communicate to an Agent // Transport is an http.Transport wrapper that adds custom http headers to communicate to an Agent
AgentTransport struct { Transport struct {
httpTransport *http.Transport httpTransport *http.Transport
signatureService portainer.DigitalSignatureService signatureService portainer.DigitalSignatureService
endpointIdentifier portainer.EndpointID endpointIdentifier portainer.EndpointID
} }
) )
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent // NewTransport 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 { func NewTransport(signatureService portainer.DigitalSignatureService, httpTransport *http.Transport) *Transport {
transport := &AgentTransport{ transport := &Transport{
httpTransport: httpTransport, httpTransport: httpTransport,
signatureService: signatureService, signatureService: signatureService,
} }
@ -26,7 +26,7 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, httpT
} }
// RoundTrip is the implementation of the the http.RoundTripper interface // 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) signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil { if err != nil {

View file

@ -48,10 +48,10 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
return proxy, nil return proxy, nil
} }
// CreateComposeProxyServer creates a new HTTP reverse proxy based on 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. // 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) { func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
return manager.proxyFactory.NewDockerComposeAgentProxy(endpoint) return manager.proxyFactory.NewAgentProxy(endpoint)
} }
// GetEndpointProxy returns the proxy associated to a key // GetEndpointProxy returns the proxy associated to a key

View file

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

View file

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

View file

@ -1,14 +1,13 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"sync" "sync"
"time"
cmap "github.com/orcaman/concurrent-map" cmap "github.com/orcaman/concurrent-map"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"k8s.io/client-go/kubernetes" "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) { func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL) 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) { func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
if tunnel.Status == portainer.EdgeAgentIdle { return nil, errors.Wrap(err, "failed activating tunnel")
err := factory.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
if err != nil {
return nil, fmt.Errorf("failed opening tunnel to endpoint: %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)
} }
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port) 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) { func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {

View file

@ -1235,7 +1235,7 @@ type (
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint
KubernetesDeployer interface { KubernetesDeployer interface {
Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error) Deploy(request *http.Request, endpoint *Endpoint, manifestFiles []string, namespace string, deployAsAdmin bool) (string, error)
ConvertCompose(data []byte) ([]byte, error) ConvertCompose(data []byte) ([]byte, error)
} }
@ -1284,6 +1284,7 @@ type (
SetTunnelStatusToRequired(endpointID EndpointID) error SetTunnelStatusToRequired(endpointID EndpointID) error
SetTunnelStatusToIdle(endpointID EndpointID) SetTunnelStatusToIdle(endpointID EndpointID)
GetTunnelDetails(endpointID EndpointID) *TunnelDetails GetTunnelDetails(endpointID EndpointID) *TunnelDetails
GetActiveTunnel(endpoint *Endpoint) (*TunnelDetails, error)
AddEdgeJob(endpointID EndpointID, edgeJob *EdgeJob) AddEdgeJob(endpointID EndpointID, edgeJob *EdgeJob)
RemoveEdgeJob(edgeJobID EdgeJobID) RemoveEdgeJob(edgeJobID EdgeJobID)
} }

View file

@ -7,9 +7,13 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
log "github.com/sirupsen/logrus"
) )
func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) error { func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) error {
logger := log.WithFields(log.Fields{"stackID": stackID})
logger.Debug("redeploying stack")
stack, err := datastore.Stack().Stack(stackID) stack, err := datastore.Stack().Stack(stackID)
if err != nil { if err != nil {
return errors.WithMessagef(err, "failed to get the stack %v", stackID) return errors.WithMessagef(err, "failed to get the stack %v", stackID)
@ -75,6 +79,12 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
if err != nil { if err != nil {
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
} }
case portainer.KubernetesStack:
logger.Debugf("deploying a kube app")
err := deployer.DeployKubernetesStack(stack, endpoint)
if err != nil {
return errors.WithMessagef(err, "failed to deploy a kubternetes app stack %v", stackID)
}
default: default:
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type) return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
} }

View file

@ -35,6 +35,10 @@ func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *port
return nil return nil
} }
func (s *noopDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) { func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) {
store, teardown := bolt.MustNewTestStore(true) store, teardown := bolt.MustNewTestStore(true)
defer teardown() defer teardown()
@ -136,12 +140,12 @@ func Test_redeployWhenChanged(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
}) })
t.Run("can NOT deploy kube stack", func(t *testing.T) { t.Run("can deploy kube app", func(t *testing.T) {
stack.Type = portainer.KubernetesStack stack.Type = portainer.KubernetesStack
store.Stack().UpdateStack(stack.ID, &stack) store.Stack().UpdateStack(stack.ID, &stack)
err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"}) err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"})
assert.EqualError(t, err, "cannot update stack, type 3 is unsupported") assert.NoError(t, err)
}) })
} }

View file

@ -1,27 +1,35 @@
package stacks package stacks
import ( import (
"os"
"sync" "sync"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/stackutils"
k "github.com/portainer/portainer/api/kubernetes"
) )
type StackDeployer interface { type StackDeployer interface {
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
} }
type stackDeployer struct { type stackDeployer struct {
lock *sync.Mutex lock *sync.Mutex
swarmStackManager portainer.SwarmStackManager swarmStackManager portainer.SwarmStackManager
composeStackManager portainer.ComposeStackManager composeStackManager portainer.ComposeStackManager
kubernetesDeployer portainer.KubernetesDeployer
} }
func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager) *stackDeployer { func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager, kubernetesDeployer portainer.KubernetesDeployer) *stackDeployer {
return &stackDeployer{ return &stackDeployer{
lock: &sync.Mutex{}, lock: &sync.Mutex{},
swarmStackManager: swarmStackManager, swarmStackManager: swarmStackManager,
composeStackManager: composeStackManager, composeStackManager: composeStackManager,
kubernetesDeployer: kubernetesDeployer,
} }
} }
@ -44,3 +52,33 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
return d.composeStackManager.Up(stack, endpoint) return d.composeStackManager.Up(stack, endpoint)
} }
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
d.lock.Lock()
defer d.lock.Unlock()
appLabels := k.KubeAppLabels{
StackID: int(stack.ID),
Name: stack.Name,
Owner: stack.CreatedBy,
}
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(nil, endpoint, manifestFilePaths, stack.Namespace, true)
if err != nil {
return errors.Wrap(err, "failed to deploy kubernetes application")
}
return nil
}

View file

@ -1,9 +1,10 @@
package stacks package stacks
import ( import (
"log"
"time" "time"
log "github.com/sirupsen/logrus"
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/scheduler" "github.com/portainer/portainer/api/scheduler"
@ -19,9 +20,10 @@ func StartStackSchedules(scheduler *scheduler.Scheduler, stackdeployer StackDepl
if err != nil { if err != nil {
return errors.Wrap(err, "Unable to parse auto update interval") return errors.Wrap(err, "Unable to parse auto update interval")
} }
stackID := stack.ID // to be captured by the scheduled function
jobID := scheduler.StartJobEvery(d, func() { jobID := scheduler.StartJobEvery(d, func() {
if err := RedeployWhenChanged(stack.ID, stackdeployer, datastore, gitService); err != nil { if err := RedeployWhenChanged(stackID, stackdeployer, datastore, gitService); err != nil {
log.Printf("[ERROR] %s\n", err) log.WithFields(log.Fields{"stackID": stackID}).WithError(err).Error("faile to auto-deploy a stack")
} }
}) })