From 0e60f4093776180909c01bc2c27f717d5d584f2b Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Tue, 31 Aug 2021 13:41:19 +1200 Subject: [PATCH] feat(kube): kube app auto update backend (#5547) --- api/chisel/tunnel.go | 28 +++ api/cmd/portainer/main.go | 8 +- api/exec/compose_stack.go | 4 +- api/exec/kubernetes_deploy.go | 231 ++++++------------ api/filesystem/write.go | 23 ++ api/filesystem/write_test.go | 48 ++++ .../handler/stacks/create_kubernetes_stack.go | 97 ++++---- .../stacks/create_kubernetes_stack_test.go | 68 ------ api/http/handler/stacks/stack_delete.go | 60 +++-- .../stacks/stack_update_git_redeploy.go | 8 +- .../handler/stacks/update_kubernetes_stack.go | 35 ++- .../factory/{docker_compose.go => agent.go} | 40 ++- .../{dockercompose => agent}/transport.go | 14 +- api/http/proxy/manager.go | 6 +- api/internal/endpointutils/endpointutils.go | 4 + api/internal/stackutils/stackutils.go | 40 +++ api/kubernetes/cli/client.go | 31 +-- api/portainer.go | 3 +- api/stacks/deploy.go | 10 + api/stacks/deploy_test.go | 8 +- api/stacks/deployer.go | 40 ++- api/stacks/scheduled.go | 8 +- 22 files changed, 450 insertions(+), 364 deletions(-) create mode 100644 api/filesystem/write.go create mode 100644 api/filesystem/write_test.go delete mode 100644 api/http/handler/stacks/create_kubernetes_stack_test.go rename api/http/proxy/factory/{docker_compose.go => agent.go} (53%) rename api/http/proxy/factory/{dockercompose => agent}/transport.go (60%) diff --git a/api/chisel/tunnel.go b/api/chisel/tunnel.go index 1306df48c..660eaf82f 100644 --- a/api/chisel/tunnel.go +++ b/api/chisel/tunnel.go @@ -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. // It sets the status to ACTIVE. func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) { diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 717c3fbc3..8436b098c 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -100,8 +100,8 @@ func initSwarmStackManager(assetsPath string, dataStorePath string, signatureSer 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 { - 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 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) - kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) + kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets) if dataStore.IsNew() { err = updateSettingsFromFlags(dataStore, flags) @@ -523,7 +523,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{ diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index 36283c6d9..da91aac8d 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -44,7 +44,7 @@ func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string { func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { url, proxy, err := w.fetchEndpointProxy(endpoint) if err != nil { - return errors.Wrap(err, "failed to featch endpoint proxy") + return errors.Wrap(err, "failed to fetch endpoint proxy") } if proxy != nil { @@ -88,7 +88,7 @@ func (w *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) ( return "", nil, nil } - proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint) + proxy, err := w.proxyManager.CreateAgentProxyServer(endpoint) if err != nil { return "", nil, err } diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go index 46a2622d0..22a6a311f 100644 --- a/api/exec/kubernetes_deploy.go +++ b/api/exec/kubernetes_deploy.go @@ -2,24 +2,22 @@ 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. @@ -30,10 +28,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,23 +40,28 @@ 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(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool, getAdminToken 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 getAdminToken { + return tokenManager.GetAdminServiceAccountToken(), nil + } + + tokenData, err := security.RetrieveTokenData(request) if err != nil { 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. // 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) - if err != nil { - return "", err +func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, manifestFiles []string, namespace string, deployAsAdmin bool) (string, error) { + token, err := deployer.getToken(request, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment, deployAsAdmin) + if err != nil { + 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") - if runtime.GOOS == "windows" { - command = path.Join(deployer.binaryPath, "kubectl.exe") - } - - args := make([]string, 0) - args = append(args, "--server", endpoint.URL) + 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 + args = append(args, "--token", token) + args = append(args, "--namespace", namespace) - 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) + var fileArgs []string + for _, path := range manifestFiles { + fileArgs = append(fileArgs, "-f") + fileArgs = append(fileArgs, strings.TrimSpace(path)) } + 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 { - 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)) + 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 +162,21 @@ func (deployer *KubernetesDeployer) ConvertCompose(data []byte) ([]byte, error) 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 +} diff --git a/api/filesystem/write.go b/api/filesystem/write.go new file mode 100644 index 000000000..235511933 --- /dev/null +++ b/api/filesystem/write.go @@ -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) +} diff --git a/api/filesystem/write_test.go b/api/filesystem/write_test.go new file mode 100644 index 000000000..89223a20e --- /dev/null +++ b/api/filesystem/write_test.go @@ -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) +} diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 83cb77f9d..e189e73a8 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -1,9 +1,9 @@ package stacks import ( - "io/ioutil" + "fmt" "net/http" - "path/filepath" + "os" "strconv" "time" @@ -17,6 +17,7 @@ import ( 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/internal/stackutils" k "github.com/portainer/portainer/api/kubernetes" ) @@ -34,7 +35,9 @@ type kubernetesGitDeploymentPayload struct { RepositoryAuthentication bool RepositoryUsername string RepositoryPassword string - FilePathInRepository string + ManifestFile string + AdditionalFiles []string + AutoUpdate *portainer.StackAutoUpdate } 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) { 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 + } return nil } @@ -104,7 +110,7 @@ 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{ + output, err := handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{ StackID: stackID, Name: stack.Name, 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} } + //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, Status: portainer.StackStatusActive, CreationDate: time.Now().Unix(), CreatedBy: user.Username, IsComposeFormat: payload.ComposeFormat, + AutoUpdate: payload.AutoUpdate, } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -170,12 +188,19 @@ 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{ + 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, Name: stack.Name, 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} } + 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} @@ -199,43 +233,14 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr 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() 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(r, endpoint, manifestFilePaths, stack.Namespace, false) } diff --git a/api/http/handler/stacks/create_kubernetes_stack_test.go b/api/http/handler/stacks/create_kubernetes_stack_test.go deleted file mode 100644 index 2bcd35ab5..000000000 --- a/api/http/handler/stacks/create_kubernetes_stack_test.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index f7ce69bf1..e692ed688 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -2,6 +2,7 @@ package stacks import ( "errors" + "fmt" "net/http" "strconv" @@ -33,12 +34,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) @@ -48,51 +49,51 @@ 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 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 { - 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) 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 endpoint", err} + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", 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} + 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} } } @@ -103,24 +104,24 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt err = handler.deleteStack(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) @@ -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 { 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 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 { - 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) 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{ @@ -163,7 +164,7 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit err = handler.deleteStack(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) @@ -173,6 +174,11 @@ func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer. if stack.Type == portainer.DockerSwarmStack { return handler.SwarmStackManager.Remove(stack, endpoint) } - - return handler.ComposeStackManager.Down(stack, endpoint) + if stack.Type == portainer.DockerComposeStack { + return handler.ComposeStackManager.Down(stack, endpoint) + } + if stack.Type == portainer.KubernetesStack { + return nil + } + return fmt.Errorf("Unsupported stack type: %v", stack.Type) } diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index 445ba9604..b0868d001 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -2,10 +2,8 @@ package stacks import ( "fmt" - "io/ioutil" "log" "net/http" - "path/filepath" "time" "github.com/asaskevich/govalidator" @@ -216,11 +214,7 @@ 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)) - 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{ + _, err := handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{ StackID: int(stack.ID), Name: stack.Name, Owner: stack.CreatedBy, diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index bb17b2ad3..d39d250d9 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -22,6 +22,7 @@ type kubernetesGitStackUpdatePayload struct { RepositoryAuthentication bool RepositoryUsername string RepositoryPassword string + AutoUpdate *portainer.StackAutoUpdate } 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) { 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 { @@ -48,6 +57,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 { @@ -60,6 +71,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 } @@ -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} } - _, 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), Name: stack.Name, 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} } - 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 } diff --git a/api/http/proxy/factory/docker_compose.go b/api/http/proxy/factory/agent.go similarity index 53% rename from api/http/proxy/factory/docker_compose.go rename to api/http/proxy/factory/agent.go index 9438117e8..a39a6327b 100644 --- a/api/http/proxy/factory/docker_compose.go +++ b/api/http/proxy/factory/agent.go @@ -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, err @@ -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) +} diff --git a/api/http/proxy/factory/dockercompose/transport.go b/api/http/proxy/factory/agent/transport.go similarity index 60% rename from api/http/proxy/factory/dockercompose/transport.go rename to api/http/proxy/factory/agent/transport.go index b9be10e01..fa6796d40 100644 --- a/api/http/proxy/factory/dockercompose/transport.go +++ b/api/http/proxy/factory/agent/transport.go @@ -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 { diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 2013647d8..ed607f24c 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -48,10 +48,10 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo 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. -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 diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 3929ce4b3..793d1bcea 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -11,6 +11,10 @@ func IsLocalEndpoint(endpoint *portainer.Endpoint) bool { 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 func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool { return endpoint.Type == portainer.KubernetesLocalEnvironment || diff --git a/api/internal/stackutils/stackutils.go b/api/internal/stackutils/stackutils.go index 7e94bff17..a24022a6e 100644 --- a/api/internal/stackutils/stackutils.go +++ b/api/internal/stackutils/stackutils.go @@ -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 +} diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 0fc40d384..766582206 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -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 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) + 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) { diff --git a/api/portainer.go b/api/portainer.go index eb2e8ac72..0a5e4134b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1235,7 +1235,7 @@ type ( // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint 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) } @@ -1284,6 +1284,7 @@ type ( SetTunnelStatusToRequired(endpointID EndpointID) error SetTunnelStatusToIdle(endpointID EndpointID) GetTunnelDetails(endpointID EndpointID) *TunnelDetails + GetActiveTunnel(endpoint *Endpoint) (*TunnelDetails, error) AddEdgeJob(endpointID EndpointID, edgeJob *EdgeJob) RemoveEdgeJob(edgeJobID EdgeJobID) } diff --git a/api/stacks/deploy.go b/api/stacks/deploy.go index ccf5eb441..7762c6af5 100644 --- a/api/stacks/deploy.go +++ b/api/stacks/deploy.go @@ -7,9 +7,13 @@ import ( "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "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 { + 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) @@ -75,6 +79,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) + 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) } diff --git a/api/stacks/deploy_test.go b/api/stacks/deploy_test.go index 58b7de913..07432b4d8 100644 --- a/api/stacks/deploy_test.go +++ b/api/stacks/deploy_test.go @@ -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) error { + return nil +} + func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) { store, teardown := bolt.MustNewTestStore(true) defer teardown() @@ -136,12 +140,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) }) } diff --git a/api/stacks/deployer.go b/api/stacks/deployer.go index d38c50cbc..36b654b07 100644 --- a/api/stacks/deployer.go +++ b/api/stacks/deployer.go @@ -1,27 +1,35 @@ package stacks import ( + "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) 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 { +func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager, kubernetesDeployer portainer.KubernetesDeployer) *stackDeployer { return &stackDeployer{ lock: &sync.Mutex{}, swarmStackManager: swarmStackManager, composeStackManager: composeStackManager, + kubernetesDeployer: kubernetesDeployer, } } @@ -44,3 +52,33 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por 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 +} diff --git a/api/stacks/scheduled.go b/api/stacks/scheduled.go index fb90ca22c..1b8ad1479 100644 --- a/api/stacks/scheduled.go +++ b/api/stacks/scheduled.go @@ -1,9 +1,10 @@ package stacks import ( - "log" "time" + log "github.com/sirupsen/logrus" + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/scheduler" @@ -19,9 +20,10 @@ func StartStackSchedules(scheduler *scheduler.Scheduler, stackdeployer StackDepl if err != nil { 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() { - if err := RedeployWhenChanged(stack.ID, stackdeployer, datastore, gitService); err != nil { - log.Printf("[ERROR] %s\n", err) + if err := RedeployWhenChanged(stackID, stackdeployer, datastore, gitService); err != nil { + log.WithFields(log.Fields{"stackID": stackID}).WithError(err).Error("faile to auto-deploy a stack") } })