From 5bc75c2015b73f4ef003e342cfb588e87385dc39 Mon Sep 17 00:00:00 2001 From: Mario Lubenka Date: Sat, 27 May 2023 21:18:08 +0200 Subject: [PATCH 1/6] feat: project deployment versions --- commands/project/deploy.go | 21 +++- .../container/docker/definitions/volume.go | 11 +- modules/container/docker/deploy.go | 89 +++++++++----- modules/container/docker/destroy.go | 2 +- .../container/docker/docker-compose/yaml.go | 9 +- modules/container/docker/system/hooks.go | 8 +- modules/container/docker/system/naming.go | 13 ++- modules/container/docker/system/ports.go | 2 +- .../docker/templates/project.tf.tmpl | 17 --- modules/proxy/caddy/deploy.go | 27 +++-- modules/proxy/nginx/certificates.go | 4 - modules/proxy/nginx/deploy.go | 110 +++++++++++------- modules/proxy/rendering.go | 2 +- project/funcs.go | 6 +- routines/implementations.go | 97 +++++++++++++-- system/context.go | 46 +++++++- system/deployments.go | 35 ++++++ system/resource.go | 24 ++-- system/resource_manager.go | 44 +++++-- 19 files changed, 410 insertions(+), 157 deletions(-) delete mode 100644 modules/container/docker/templates/project.tf.tmpl create mode 100644 system/deployments.go diff --git a/commands/project/deploy.go b/commands/project/deploy.go index 2b03473a..4ad37bf8 100644 --- a/commands/project/deploy.go +++ b/commands/project/deploy.go @@ -2,6 +2,7 @@ package project import ( "fmt" + xfs "github.com/saitho/golang-extended-fs/v2" "github.com/spf13/cobra" "golang.org/x/exp/slices" @@ -50,11 +51,11 @@ var DeployApplication = func() *cobra.Command { } if autoConfirm { - _ = taskRunner.RunTask(routines.CreateResources) + err = taskRunner.RunTask(routines.CreateResources) } else { // Confirm resource creation fmt.Println("\nStackHead will try to create or update the following resources:") - for _, resourceGroup := range system.Context.Resources { + for _, resourceGroup := range system.Context.CurrentDeployment.ResourceGroups { for _, resource := range resourceGroup.Resources { fmt.Println(fmt.Sprintf("- %s", resource.ToString(false))) } @@ -62,14 +63,28 @@ var DeployApplication = func() *cobra.Command { fmt.Println("") fmt.Print("Please confirm with \"y\" or \"yes\": ") if askForConfirmation() { - _ = taskRunner.RunTask(routines.CreateResources) + err = taskRunner.RunTask(routines.CreateResources) + } else { + // Abort deployment -> delete + if err := xfs.DeleteFolder("ssh://"+system.Context.CurrentDeployment.GetPath(), true); err != nil { + fmt.Print("Unable to remove deployment directory.") + return + } + fmt.Print("Deployment aborted.") + return } } + if err != nil { + // ensure rollback is performed due to errors caused by StackHead module Run scripts not resource creation + routines.RollbackResources.Disabled = false + } + if !noRollback { // Rollback may be skipped if CreateResources does not trigger a rollback _ = taskRunner.RunTask(routines.RollbackResources) } + _ = taskRunner.RunTask(routines.FinalizeDeployment) }, } command.PersistentFlags().BoolVar(&autoConfirm, "autoconfirm", false, "Whether to auto-confirm resource changes") diff --git a/modules/container/docker/definitions/volume.go b/modules/container/docker/definitions/volume.go index 190d27bd..5bfdbb1f 100644 --- a/modules/container/docker/definitions/volume.go +++ b/modules/container/docker/definitions/volume.go @@ -3,24 +3,27 @@ package container_docker_definitions import ( "github.com/getstackhead/stackhead/project" "github.com/getstackhead/stackhead/system" + "path" ) type DockerPaths struct { - BaseDir string + DataDir string + DeploymentDir string } func GetDockerPaths() DockerPaths { return DockerPaths{ - BaseDir: system.Context.Project.GetRuntimeDataDirectoryPath() + "/container", + DataDir: path.Join(system.Context.Project.GetRuntimeDataDirectoryPath(), "container"), + DeploymentDir: path.Join(system.Context.CurrentDeployment.GetPath(), "container"), } } func (p DockerPaths) GetHooksDir() string { - return p.BaseDir + "/hooks" + return path.Join(p.DeploymentDir, "hooks") } func (p DockerPaths) getDataDir() string { - return p.BaseDir + "/data" + return path.Join(p.DataDir, "data") } func (p DockerPaths) GetServiceDataDir(service project.ContainerService, volume project.ContainerServiceVolume) string { diff --git a/modules/container/docker/deploy.go b/modules/container/docker/deploy.go index 3f5b4dbd..51b151e5 100644 --- a/modules/container/docker/deploy.go +++ b/modules/container/docker/deploy.go @@ -138,25 +138,36 @@ func (m Module) Deploy(modulesSettings interface{}) error { return err } - composeFileRemotePath := system.Context.Project.GetDirectoryPath() + "/docker-compose.yaml" - - hasRemoteFile, err := xfs.HasFile("ssh://" + composeFileRemotePath) - if err != nil && err.Error() == "file does not exist" { - hasRemoteFile = false - } else if err != nil { - return fmt.Errorf("Unable to check state of remote docker-compose.yaml from previous deployment: " + err.Error()) + dockerComposeResource := system.Resource{ + Type: system.TypeFile, + Operation: system.OperationCreate, + Name: "docker-compose.yaml", } + oldComposeFilePath := "" var remoteComposeObjMap map[string]interface{} - if hasRemoteFile { - remoteComposeObj := docker_compose.DockerCompose{} - remoteComposeContent, err := xfs.ReadFile("ssh://" + composeFileRemotePath) - if err := yaml.Unmarshal([]byte(remoteComposeContent), &remoteComposeObj); err != nil { - return fmt.Errorf("unable to read remote docker-compose.yaml file from previous deployment: " + err.Error()) - } - remoteComposeObjMap, err = remoteComposeObj.Map() + if system.Context.LatestDeployment != nil { + dockerComposeFilePathOld, err := system.Context.LatestDeployment.GetResourcePath(dockerComposeResource) if err != nil { - return fmt.Errorf("unable to process remote docker-compose.yaml file from previous deployment: " + err.Error()) + return err + } + oldComposeFilePath = dockerComposeFilePathOld + hasRemoteFile, err := xfs.HasFile("ssh://" + oldComposeFilePath) + if err != nil && hasRemoteFile { + remoteComposeContent, err := xfs.ReadFile("ssh://" + oldComposeFilePath) + if err != nil { + return fmt.Errorf("unable to read remote docker-compose.yaml file from previous deployment: " + err.Error()) + } + remoteComposeObj := docker_compose.DockerCompose{} + if err := yaml.Unmarshal([]byte(remoteComposeContent), &remoteComposeObj); err != nil { + return fmt.Errorf("unable to read remote docker-compose.yaml file from previous deployment: " + err.Error()) + } + remoteComposeObjMap, err = remoteComposeObj.Map() + if err != nil { + return fmt.Errorf("unable to process remote docker-compose.yaml file from previous deployment: " + err.Error()) + } + } else if err != nil && err.Error() != "file does not exist" { + return fmt.Errorf("Unable to check state of remote docker-compose.yaml from previous deployment: " + err.Error()) } } @@ -177,17 +188,13 @@ func (m Module) Deploy(modulesSettings interface{}) error { if err != nil { return err } + dockerComposeResource.Content = composeFileContent - system.Context.Resources = append(system.Context.Resources, system.ResourceGroup{ - Name: "container-docker-" + system.Context.Project.Name + "-composefile", - Resources: []system.Resource{ - { - Type: system.TypeFile, - Operation: system.OperationCreate, - Name: composeFileRemotePath, - Content: composeFileContent, - }, - }, + dockerComposeFilePathNew, _ := system.Context.CurrentDeployment.GetResourcePath(dockerComposeResource) + + system.Context.CurrentDeployment.ResourceGroups = append(system.Context.CurrentDeployment.ResourceGroups, system.ResourceGroup{ + Name: "container-docker-" + system.Context.Project.Name + "-composefile", + Resources: []system.Resource{dockerComposeResource}, }) var containerResources []system.Resource @@ -202,14 +209,24 @@ func (m Module) Deploy(modulesSettings interface{}) error { }) } - system.Context.Resources = append(system.Context.Resources, system.ResourceGroup{ + system.Context.CurrentDeployment.ResourceGroups = append(system.Context.CurrentDeployment.ResourceGroups, system.ResourceGroup{ Name: "container-docker-" + system.Context.Project.Name + "-containers", Resources: containerResources, ApplyResourceFunc: func() error { + if oldComposeFilePath != "" { + // Stop old Docker Compose containers + // todo: allow using either docker-compose or "docker compose" whichever is available (prefer "docker compose") + if _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"down"}, WorkingDir: path.Dir(oldComposeFilePath)}); err != nil { + if stderr.Len() > 0 { + return fmt.Errorf("Unable to stop old Docker containers: " + stderr.String()) + } + return fmt.Errorf("Unable to stop old Docker containers: " + err.Error()) + } + } + // Start Docker Compose // todo: allow using either docker-compose or "docker compose" whichever is available (prefer "docker compose") - _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"up", "-d"}, WorkingDir: system.Context.Project.GetDirectoryPath()}) - if err != nil { + if _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"up", "-d"}, WorkingDir: path.Dir(dockerComposeFilePathNew)}); err != nil { if stderr.Len() > 0 { return fmt.Errorf("Unable to start Docker containers: " + stderr.String()) } @@ -223,14 +240,24 @@ func (m Module) Deploy(modulesSettings interface{}) error { return nil }, RollbackResourceFunc: func() error { + // Start old containers again + if oldComposeFilePath != "" { + // todo: allow using either docker-compose or "docker compose" whichever is available (prefer "docker compose") + if _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"up", "-d"}, WorkingDir: path.Dir(oldComposeFilePath)}); err != nil { + if stderr.Len() > 0 { + return fmt.Errorf("Unable to stop Docker containers: " + stderr.String()) + } + return fmt.Errorf("Unable to start old Docker containers: " + err.Error()) + } + } + // Stop Docker Compose // todo: allow using either docker-compose or "docker compose" whichever is available (prefer "docker compose") - _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"down"}, WorkingDir: system.Context.Project.GetDirectoryPath()}) - if err != nil { + if _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"down"}, WorkingDir: path.Dir(dockerComposeFilePathNew)}); err != nil { if stderr.Len() > 0 { return fmt.Errorf("Unable to stop Docker containers: " + stderr.String()) } - return fmt.Errorf("Unable to stop Docker containers: " + err.Error()) + return fmt.Errorf("Unable to stop new Docker containers: " + err.Error()) } return nil }, diff --git a/modules/container/docker/destroy.go b/modules/container/docker/destroy.go index 8a276ff2..8203b706 100644 --- a/modules/container/docker/destroy.go +++ b/modules/container/docker/destroy.go @@ -15,7 +15,7 @@ func (m Module) Destroy(modulesSettings interface{}) error { // Stop and remove containers // todo: allow using either docker-compose or "docker compose" whichever is available (prefer "docker compose") - _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"down"}, WorkingDir: system.Context.Project.GetDirectoryPath()}) + _, stderr, err := system.RemoteRun("docker compose", system.RemoteRunOpts{Args: []string{"down"}, WorkingDir: system.Context.CurrentDeployment.GetPath()}) if err != nil { if stderr.Len() > 0 { return fmt.Errorf("Unable to stop Docker containers: " + stderr.String()) diff --git a/modules/container/docker/docker-compose/yaml.go b/modules/container/docker/docker-compose/yaml.go index 2b5292a4..8b6a311a 100644 --- a/modules/container/docker/docker-compose/yaml.go +++ b/modules/container/docker/docker-compose/yaml.go @@ -17,7 +17,7 @@ func BuildDockerCompose(project *project.Project) (DockerCompose, error) { dockerPaths := container_docker_definitions.GetDockerPaths() compose := DockerCompose{ Version: "2.4", - Networks: map[string]Network{"stackhead-network-" + project.Name: {}}, + Networks: map[string]Network{docker_system.NetworkName(project.Name, system.Context.CurrentDeployment): {}}, Services: map[string]Services{}, Volumes: map[string]Volume{}, } @@ -36,7 +36,6 @@ func BuildDockerCompose(project *project.Project) (DockerCompose, error) { continue } vol := Volume{} - serviceName := service.Name vol.DriverOpts.Type = "none" vol.DriverOpts.O = "bind" if volume.Type == "local" { @@ -46,7 +45,7 @@ func BuildDockerCompose(project *project.Project) (DockerCompose, error) { } else if volume.Type == "custom" { vol.DriverOpts.Device = volume.Src } - compose.Volumes[GetVolumeSrcKey(project.Name, serviceName, volume)] = vol + compose.Volumes[GetVolumeSrcKey(project.Name, service.Name, volume)] = vol } } @@ -89,12 +88,12 @@ func addService(compose *DockerCompose, project *project.Project, service projec } compose.Services[service.Name] = Services{ - ContainerName: docker_system.ContainerName(project.Name, service.Name), + ContainerName: docker_system.ContainerName(project.Name, service.Name, system.Context.CurrentDeployment), Image: service.Image, Restart: "unless-stopped", Labels: map[string]string{"stackhead.project": project.Name}, User: service.User, - Networks: map[string]ServiceNetwork{"stackhead-network-" + project.Name: {Aliases: []string{service.Name}}}, + Networks: map[string]ServiceNetwork{docker_system.NetworkName(project.Name, system.Context.CurrentDeployment): {Aliases: []string{service.Name}}}, Volumes: volumes, Ports: ports, Environment: service.Environment, diff --git a/modules/container/docker/system/hooks.go b/modules/container/docker/system/hooks.go index 78c709b0..3b71ff83 100644 --- a/modules/container/docker/system/hooks.go +++ b/modules/container/docker/system/hooks.go @@ -45,14 +45,14 @@ func ExecuteHook(hookName string) error { // copy file onto container and run it..... containerLocation := path.Join("/", file.File) - containerName := ContainerName(system.Context.Project.Name, file.Service) + containerName := ContainerName(system.Context.Project.Name, file.Service, system.Context.CurrentDeployment) _, err := system.SimpleRemoteRun("docker", system.RemoteRunOpts{ Args: []string{ "cp", filePath, containerName + ":" + containerLocation, }, - WorkingDir: system.Context.Project.GetDirectoryPath(), + WorkingDir: system.Context.CurrentDeployment.GetPath(), }) if err != nil { return fmt.Errorf("Unable to copy file %s to container %s: \"%s\"", file.File, containerName, err.Error()) @@ -64,7 +64,7 @@ func ExecuteHook(hookName string) error { containerName, "chmod +x " + containerLocation, }, - WorkingDir: system.Context.Project.GetDirectoryPath(), + WorkingDir: system.Context.CurrentDeployment.GetPath(), }) if err != nil { return fmt.Errorf("Unable to copy file %s to container %s: \"%s\"", file.File, containerName, err.Error()) @@ -76,7 +76,7 @@ func ExecuteHook(hookName string) error { containerName, containerLocation, }, - WorkingDir: system.Context.Project.GetDirectoryPath(), + WorkingDir: system.Context.CurrentDeployment.GetPath(), }) if err != nil { return fmt.Errorf("Unable to run %s on container %s: \"%s\"", file, containerName, err.Error()) diff --git a/modules/container/docker/system/naming.go b/modules/container/docker/system/naming.go index 90be2817..1a31a86e 100644 --- a/modules/container/docker/system/naming.go +++ b/modules/container/docker/system/naming.go @@ -1,7 +1,14 @@ package docker_system -import "fmt" +import ( + "fmt" + "github.com/getstackhead/stackhead/system" +) -func ContainerName(projectName string, serviceName string) string { - return fmt.Sprintf("stackhead-%s-%s", projectName, serviceName) +func ContainerName(projectName string, serviceName string, deployment system.Deployment) string { + return fmt.Sprintf("stackhead-%s-%s-v%d", projectName, serviceName, deployment.Version) +} + +func NetworkName(projectName string, deployment system.Deployment) string { + return fmt.Sprintf("stackhead-network-%s-v%d", projectName, deployment.Version) } diff --git a/modules/container/docker/system/ports.go b/modules/container/docker/system/ports.go index 9cb0acf1..ce021a7d 100644 --- a/modules/container/docker/system/ports.go +++ b/modules/container/docker/system/ports.go @@ -16,7 +16,7 @@ func GetPortMap(project *project.Project) (map[string]int, error) { // find ports for running containers for _, service := range project.Container.Services { - res, _, err := system.RemoteRun("docker", system.RemoteRunOpts{Args: []string{"port", "stackhead-" + project.Name + "-" + service.Name}}) + res, _, err := system.RemoteRun("docker", system.RemoteRunOpts{Args: []string{"port", ContainerName(project.Name, service.Name, system.Context.CurrentDeployment)}}) if err == nil { // ignore error (container not running) // e.g. 80/tcp -> 0.0.0.0:49155 re := regexp.MustCompile(`(?P\d+)\/tcp -> 0\.0\.0\.0:(?P\d+)`) diff --git a/modules/container/docker/templates/project.tf.tmpl b/modules/container/docker/templates/project.tf.tmpl deleted file mode 100644 index 2c92eec8..00000000 --- a/modules/container/docker/templates/project.tf.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -# This file was generated by StackHead. Do not modify it. - -resource "docker_network" "stackhead-network-{{ $.Context.Project.Name }}" { - provider = docker.{{ $.Context.Project.Name }} - name = "stackhead-network-{{ $.Context.Project.Name }}" -} - -resource "docker_container" "stackhead-{{ $.Context.Project.Name }}-{{ $service.Name }}" { -{{- if $service.Hooks }} - {{- if $service.Hooks.ExecuteAfterSetup }} - provisioner "local-exec" { - command = "docker cp {{ $.DockerPaths.GetHooksDir }}/afterSetup_{{ base $service.Hooks.ExecuteAfterSetup }} stackhead-{{ $.Context.Project.Name }}-{{ $service.Name }}:/afterSetup_{{ base $service.Hooks.ExecuteAfterSetup }} && docker exec stackhead-{{ $.Context.Project.Name }}-{{ $service.Name }} sh /afterSetup_{{ base $service.Hooks.ExecuteAfterSetup }}" - } - {{- end }} -{{- end }} -} -{{end}} diff --git a/modules/proxy/caddy/deploy.go b/modules/proxy/caddy/deploy.go index 24bc7655..2e516a3f 100644 --- a/modules/proxy/caddy/deploy.go +++ b/modules/proxy/caddy/deploy.go @@ -17,20 +17,23 @@ func (Module) Deploy(modulesSettings interface{}) error { return err } - projectCaddyLocation := system.Context.Project.GetDirectoryPath() + "/Caddyfile" + caddyFileResource := system.Resource{ + Type: system.TypeFile, + Operation: system.OperationCreate, + Name: "Caddyfile", + Content: caddyDirectives, + } - system.Context.Resources = append(system.Context.Resources, system.ResourceGroup{ - Name: "proxy-caddy-" + system.Context.Project.Name + "-caddyfile", - Resources: []system.Resource{ - { - Type: system.TypeFile, - Operation: system.OperationCreate, - Name: projectCaddyLocation, - Content: caddyDirectives, - }, - }, + caddyFilePath, err := system.Context.CurrentDeployment.GetResourcePath(caddyFileResource) + if err != nil { + return err + } + + system.Context.CurrentDeployment.ResourceGroups = append(system.Context.CurrentDeployment.ResourceGroups, system.ResourceGroup{ + Name: "proxy-caddy-" + system.Context.Project.Name + "-caddyfile", + Resources: []system.Resource{caddyFileResource}, ApplyResourceFunc: func() error { - if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-sf " + projectCaddyLocation + " /etc/caddy/conf.d/stackhead_" + system.Context.Project.Name + ".conf"}}); err != nil { + if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-sf " + caddyFilePath + " /etc/caddy/conf.d/stackhead_" + system.Context.Project.Name + ".conf"}}); err != nil { return fmt.Errorf("Unable to symlink project Caddyfile: " + err.Error()) } if _, err := system.SimpleRemoteRun("systemctl", system.RemoteRunOpts{Args: []string{"reload", "caddy"}, Sudo: true}); err != nil { diff --git a/modules/proxy/nginx/certificates.go b/modules/proxy/nginx/certificates.go index b47c3d5a..91d6d9b2 100644 --- a/modules/proxy/nginx/certificates.go +++ b/modules/proxy/nginx/certificates.go @@ -14,10 +14,6 @@ func GetSnakeoilPaths() (string, string) { return path.Join(CertificatesDirectory, "fullchain_snakeoil.pem"), path.Join(CertificatesDirectory, "privkey_snakeoil.pem") } -func GetCertificateDirectoryPath(p *project.Project) string { - return path.Join(config.ProjectsRootDirectory, p.Name, "certificates") -} - func GetCertificatesDirectory(p *project.Project) string { return path.Join(CertificatesDirectory, p.Name) } diff --git a/modules/proxy/nginx/deploy.go b/modules/proxy/nginx/deploy.go index 8be7bed5..5c0e74d1 100644 --- a/modules/proxy/nginx/deploy.go +++ b/modules/proxy/nginx/deploy.go @@ -2,9 +2,10 @@ package proxy_nginx import ( "fmt" - xfs "github.com/saitho/golang-extended-fs/v2" "path" + xfs "github.com/saitho/golang-extended-fs/v2" + "github.com/getstackhead/stackhead/config" "github.com/getstackhead/stackhead/modules/proxy" "github.com/getstackhead/stackhead/project" @@ -14,7 +15,6 @@ import ( type Paths struct { RootDirectory string CertificatesProjectDirectory string - ProjectCertificatesDirectory string ProjectsRootDirectory string AcmeChallengesDirectory string SnakeoilFullchainPath string @@ -30,7 +30,6 @@ func getPaths() Paths { return Paths{ RootDirectory: config.RootDirectory, CertificatesProjectDirectory: GetCertificatesDirectory(system.Context.Project), - ProjectCertificatesDirectory: GetCertificateDirectoryPath(system.Context.Project), ProjectsRootDirectory: config.ProjectsRootDirectory, AcmeChallengesDirectory: AcmeChallengesDirectory, SnakeoilFullchainPath: SnakeoilFullchainPath, @@ -118,50 +117,79 @@ func (Module) Deploy(_modulesSettings interface{}) error { fmt.Println("Deploy step") paths := getPaths() - if err := xfs.CreateFolder("ssh://" + paths.ProjectCertificatesDirectory); err != nil { - return err - } if err := xfs.CreateFolder("ssh://" + paths.CertificatesProjectDirectory); err != nil { return err } serverConfig := buildServerConfig(system.Context.Project, proxy.Context.AllPorts) - nginxProjectLocation := system.Context.Project.GetDirectoryPath() + "/nginx.conf" - if err = xfs.WriteFile("ssh://"+nginxProjectLocation, serverConfig); err != nil { - return err - } - - // Symlink project certificate files to snakeoil files after initial creation - if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-s " + paths.SnakeoilFullchainPath + " " + paths.CertificatesProjectDirectory + "/fullchain.pem"}, AllowFail: true}); err != nil { - return fmt.Errorf("Unable to symlink snakeoil full chain: " + err.Error()) - } - if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-s " + paths.SnakeoilPrivkeyPath + " " + paths.CertificatesProjectDirectory + "/privkey.pem"}, AllowFail: true}); err != nil { - return fmt.Errorf("Unable to symlink snakeoil privkey: " + err.Error()) - } - - if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-sf " + nginxProjectLocation + " /etc/nginx/sites-available/stackhead_" + system.Context.Project.Name + ".conf"}}); err != nil { - return fmt.Errorf("Unable to symlink project Nginx file: " + err.Error()) - } - if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-sf /etc/nginx/sites-available/stackhead_" + system.Context.Project.Name + ".conf " + moduleSettings.Config.VhostPath + "/stackhead_" + system.Context.Project.Name + ".conf"}}); err != nil { - return fmt.Errorf("Unable to enable project Nginx file: " + err.Error()) - } - // first reload so webserver config works for ACME request - if _, err := system.SimpleRemoteRun("systemctl", system.RemoteRunOpts{Args: []string{"reload", "nginx"}, Sudo: true}); err != nil { - return fmt.Errorf("Unable to reload Nginx service: " + err.Error()) - } - - certMail := "certificates-noreply@stackhead.io" - if len(moduleSettings.CertificatesEmail) > 0 { - certMail = moduleSettings.CertificatesEmail - } - if err := generateCertificates(paths, certMail); err != nil { - return fmt.Errorf("Unable to generate certificates: " + err.Error()) - } - // reload Nginx again so certificates take effect - if _, err := system.SimpleRemoteRun("systemctl", system.RemoteRunOpts{Args: []string{"reload", "nginx"}, Sudo: true}); err != nil { - return fmt.Errorf("Unable to reload Nginx service: " + err.Error()) - } + nginxConfigResource := system.Resource{ + Type: system.TypeFile, + Operation: system.OperationCreate, + Name: "nginx.conf", + Content: serverConfig, + } + nginxConfigResourcePath, _ := system.Context.CurrentDeployment.GetResourcePath(nginxConfigResource) + system.Context.CurrentDeployment.ResourceGroups = append(system.Context.CurrentDeployment.ResourceGroups, system.ResourceGroup{ + Name: "proxy-nginx-" + system.Context.Project.Name, + Resources: []system.Resource{ + system.Resource{ + Type: system.TypeFolder, + Operation: system.OperationCreate, + Name: "certificates", + }, + nginxConfigResource, + // Symlink project certificate files to snakeoil files after initial creation + system.Resource{ + Type: system.TypeLink, + Operation: system.OperationCreate, + Name: paths.CertificatesProjectDirectory + "/fullchain.pem", + ExternalResource: true, + LinkSource: paths.SnakeoilFullchainPath, + }, + system.Resource{ + Type: system.TypeLink, + Operation: system.OperationCreate, + Name: paths.CertificatesProjectDirectory + "/privkey.pem", + ExternalResource: true, + LinkSource: paths.SnakeoilPrivkeyPath, + }, + system.Resource{ + Type: system.TypeLink, + Operation: system.OperationCreate, + Name: "/etc/nginx/sites-available/stackhead_" + system.Context.Project.Name + ".conf", + ExternalResource: true, + LinkSource: nginxConfigResourcePath, + EnforceLink: true, + }, + system.Resource{ + Type: system.TypeLink, + Operation: system.OperationCreate, + Name: moduleSettings.Config.VhostPath + "/stackhead_" + system.Context.Project.Name + ".conf", + ExternalResource: true, + LinkSource: "/etc/nginx/sites-available/stackhead_" + system.Context.Project.Name + ".conf", + EnforceLink: true, + }, + }, + ApplyResourceFunc: func() error { + // first reload so webserver config works for ACME request + if _, err := system.SimpleRemoteRun("systemctl", system.RemoteRunOpts{Args: []string{"reload", "nginx"}, Sudo: true}); err != nil { + return fmt.Errorf("Unable to reload Nginx service: " + err.Error()) + } + certMail := "certificates-noreply@stackhead.io" + if len(moduleSettings.CertificatesEmail) > 0 { + certMail = moduleSettings.CertificatesEmail + } + if err := generateCertificates(paths, certMail); err != nil { + return fmt.Errorf("Unable to generate certificates: " + err.Error()) + } + // reload Nginx again so certificates take effect + if _, err := system.SimpleRemoteRun("systemctl", system.RemoteRunOpts{Args: []string{"reload", "nginx"}, Sudo: true}); err != nil { + return fmt.Errorf("Unable to reload Nginx service: " + err.Error()) + } + return nil + }, + }) return nil } diff --git a/modules/proxy/rendering.go b/modules/proxy/rendering.go index e7cbc55f..b789c333 100644 --- a/modules/proxy/rendering.go +++ b/modules/proxy/rendering.go @@ -38,7 +38,7 @@ var FuncMap = template.FuncMap{ return auths }, "getExternalPort": func(service string, internalPort int) string { - for _, resourceGroup := range system.Context.Resources { + for _, resourceGroup := range system.Context.CurrentDeployment.ResourceGroups { for _, resource := range resourceGroup.Resources { if resource.Type != system.TypeContainer { continue diff --git a/project/funcs.go b/project/funcs.go index 21b4416d..d5862ba0 100644 --- a/project/funcs.go +++ b/project/funcs.go @@ -10,6 +10,10 @@ func (project *Project) GetDirectoryPath() string { return path.Join(config.ProjectsRootDirectory, project.Name) } +func (project *Project) GetDeploymentsPath() string { + return path.Join(config.ProjectsRootDirectory, project.Name, "deployments") +} + func (project *Project) GetRuntimeDataDirectoryPath() string { - return path.Join(config.ProjectsRootDirectory, project.Name, "runtime") + return path.Join(config.ProjectsRootDirectory, project.Name, "data") } diff --git a/routines/implementations.go b/routines/implementations.go index e0952881..561219a5 100644 --- a/routines/implementations.go +++ b/routines/implementations.go @@ -2,11 +2,14 @@ package routines import ( "fmt" - "strings" - "github.com/chelnak/ysmrr" xfs "github.com/saitho/golang-extended-fs/v2" logger "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "path" + "sort" + "strings" + "time" "github.com/getstackhead/stackhead/project" "github.com/getstackhead/stackhead/system" @@ -40,6 +43,49 @@ var PrepareProjectTask = func(projectDefinition *project.Project) Task { if err := xfs.CreateFolder("ssh://" + projectDefinition.GetDirectoryPath()); err != nil { return err } + if err := xfs.CreateFolder("ssh://" + projectDefinition.GetDeploymentsPath()); err != nil { + return err + } + + // Find latest deployment + files, err := xfs.ListFolders("ssh://" + projectDefinition.GetDeploymentsPath()) + if err != nil { + return err + } + if files != nil { + // newest files at the top + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime().After(files[j].ModTime()) + }) + for _, file := range files { + if file.IsDir() && system.MatchDeploymentNaming(file.Name()) { + fullPath := path.Join(projectDefinition.GetDeploymentsPath(), file.Name()) + latestDeployment, err := system.GetDeploymentByPath(fullPath) + if err != nil { + return err + } + if !latestDeployment.RolledBack { + latestDeployment.Project = system.Context.Project + system.Context.LatestDeployment = latestDeployment + break + } + } + } + } + newVersion := 1 + if system.Context.LatestDeployment != nil { + newVersion = system.Context.LatestDeployment.Version + 1 + } + system.Context.CurrentDeployment = system.Deployment{ + Version: newVersion, + DateStart: time.Now(), + Project: system.Context.Project, + } + + // Create folder for new deployment + if err := xfs.CreateFolder("ssh://" + system.Context.CurrentDeployment.GetPath()); err != nil { + return err + } return nil }, ErrorAsErrorMessage: true, @@ -88,21 +134,34 @@ var RollbackResources = Task{ spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(true)) matched, err := system.RollbackResourceOperation(resource) if !matched || err == nil { - spinner.Complete() + if spinner != nil { + spinner.Complete() + } } else if err != nil { errors = append(errors, fmt.Errorf("Rollback error: %s", err)) - spinner.Error() + if spinner != nil { + spinner.Error() + } } } } - if len(errors) == 0 { - return nil - } - errorMessages := []string{"The following errors occurred:"} + + // Mark deployment as rolled back + system.Context.CurrentDeployment.RolledBack = true for _, err2 := range errors { - errorMessages = append(errorMessages, "- "+err2.Error()) + system.Context.CurrentDeployment.RollbackErrors = append(system.Context.CurrentDeployment.RollbackErrors, err2.Error()) } - return fmt.Errorf(strings.Join(errorMessages, "\n")) + + if len(system.Context.CurrentDeployment.RollbackErrors) > 0 { + return fmt.Errorf("The following errors occurred:\n" + strings.Join(system.Context.CurrentDeployment.RollbackErrors, "\n")) + } + + // Delete deployment version + //if err := xfs.DeleteFolder("ssh://"+system.Context.CurrentDeployment.GetPath(), true); err != nil { + // return fmt.Errorf("unable to remove deployment folder: " + err.Error()) + //} + + return nil }, } @@ -112,7 +171,7 @@ var CreateResources = Task{ var errors []error var uncompletedSpinners []*ysmrr.Spinner - for _, resourceGroup := range system.Context.Resources { + for _, resourceGroup := range system.Context.CurrentDeployment.ResourceGroups { for _, resource := range resourceGroup.Resources { spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(false)) processed, err := system.ApplyResourceOperation(resource) @@ -165,3 +224,19 @@ var CreateResources = Task{ return fmt.Errorf(strings.Join(errorMessages, "\n")) }, } + +var FinalizeDeployment = Task{ + Name: "Finalizing deployment", + Run: func(r *Task) error { + system.Context.CurrentDeployment.DateEnd = time.Now() + resourcesPath := path.Join(system.Context.CurrentDeployment.GetPath(), "deployment.yaml") + yamlString, err := yaml.Marshal(system.Context.CurrentDeployment) + if err != nil { + return err + } + if err = xfs.WriteFile("ssh://"+resourcesPath, string(yamlString)); err != nil { + return err + } + return nil + }, +} diff --git a/system/context.go b/system/context.go index fbb97525..1a6598ed 100644 --- a/system/context.go +++ b/system/context.go @@ -2,12 +2,14 @@ package system import ( "fmt" - logger "github.com/sirupsen/logrus" "net" "os" "path" + "strconv" + "time" "github.com/saitho/golang-extended-fs/v2/sftp" + logger "github.com/sirupsen/logrus" "github.com/getstackhead/stackhead/config" "github.com/getstackhead/stackhead/project" @@ -29,13 +31,49 @@ func (c ContextAuthenticationStruct) GetPublicKeyPath() string { return path.Join(c.LocalAuthenticationDir, "public_key.pem") } +type Deployment struct { + Version int + DateStart time.Time + DateEnd time.Time + Project *project.Project `yaml:"-"` + + RolledBack bool + RollbackErrors []string + + ResourceGroups []ResourceGroup +} + +func (d Deployment) GetResourcePath(resource Resource) (string, error) { + if resource.Type != TypeFile && resource.Type != TypeFolder && resource.Type != TypeLink { + return "", fmt.Errorf("not a file, folder or link resource") + } + if resource.ExternalResource { + if !path.IsAbs(resource.Name) { + return "", fmt.Errorf("expected absolute path in Name as ExternalResource is set to true") + } + return resource.Name, nil + } + return path.Join(d.GetPath(), resource.Name), nil +} + +func (d Deployment) GetPath() string { + return path.Join(d.Project.GetDeploymentsPath(), "v"+strconv.Itoa(d.Version)) +} + +func (d Deployment) Serialize() string { + return "" +} + type ContextStruct struct { - TargetHost net.IP - CurrentAction string + TargetHost net.IP + + CurrentAction string + LatestDeployment *Deployment + CurrentDeployment Deployment + Project *project.Project IsCI bool Authentication ContextAuthenticationStruct - Resources []ResourceGroup ProxyModule Module ContainerModule Module diff --git a/system/deployments.go b/system/deployments.go new file mode 100644 index 00000000..b2b07a52 --- /dev/null +++ b/system/deployments.go @@ -0,0 +1,35 @@ +package system + +import ( + "fmt" + "regexp" + + xfs "github.com/saitho/golang-extended-fs/v2" + "gopkg.in/yaml.v3" + "path/filepath" +) + +func MatchDeploymentNaming(folderName string) bool { + pattern := regexp.MustCompile(`(?m)^v(\d+)$`) + return pattern.MatchString(folderName) +} + +func GetDeploymentByPath(path string) (*Deployment, error) { + deployment := Deployment{} + if !MatchDeploymentNaming(filepath.Base(path)) { + return nil, fmt.Errorf("last folder in path should be a version folder") + } + deploymentFilePath := "ssh://" + filepath.Join(path, "deployment.yaml") + hasDeploymentFile, err := xfs.HasFile(deploymentFilePath) + if err != nil { + return nil, err + } + if !hasDeploymentFile { + return nil, fmt.Errorf("Missing deployment file in folder") + } + deploymentFile, err := xfs.ReadFile(deploymentFilePath) + if err = yaml.Unmarshal([]byte(deploymentFile), &deployment); err != nil { + return nil, err + } + return &deployment, nil +} diff --git a/system/resource.go b/system/resource.go index 0dba1ed4..8e3edff0 100644 --- a/system/resource.go +++ b/system/resource.go @@ -11,6 +11,8 @@ type Type string const ( TypeFile Type = "file" + TypeFolder Type = "folder" + TypeLink Type = "link" TypeContainer Type = "container" ) @@ -27,26 +29,34 @@ type ResourceGroup struct { Name string Resources []Resource - ApplyResourceFunc ApplyResourceFuncType - RollbackResourceFunc RollbackResourceFuncType + ApplyResourceFunc ApplyResourceFuncType `yaml:"-"` + RollbackResourceFunc RollbackResourceFuncType `yaml:"-"` } type Resource struct { Type Type - Operation Operation + Operation Operation `yaml:"-"` + + // if set the Name refers to an external resource. for files an absolute path is expected + ExternalResource bool `yaml:"externalResource,omitempty"` // name of the resource (e.g. file name, container name) Name string // contents of resource, if any - Content string + Content string `yaml:"-"` // name of the associated service - ServiceName string + ServiceName string `yaml:"serviceName,omitempty"` // for container resources - Ports []string - ImageName string + Ports []string `yaml:"ports,omitempty"` + ImageName string `yaml:"imageName,omitempty"` + + // for link resources + LinkSource string `yaml:"linkSource,omitempty"` + // EnforceLink is true, then symlink is created with force and will not ignore errors + EnforceLink bool `yaml:"-"` } func (r Resource) GetOperationLabel(invertOperation bool) string { diff --git a/system/resource_manager.go b/system/resource_manager.go index 4344f2fd..245ec414 100644 --- a/system/resource_manager.go +++ b/system/resource_manager.go @@ -2,35 +2,65 @@ package system import ( "fmt" - xfs "github.com/saitho/golang-extended-fs/v2" ) func ApplyResourceOperation(resource Resource) (bool, error) { - // FILE - if resource.Type == TypeFile { + resourceFilePath, _ := Context.CurrentDeployment.GetResourcePath(resource) + + switch resource.Type { + case TypeFile: if resource.Operation == OperationCreate { // TODO: backup if file exists - if err := xfs.WriteFile("ssh://"+resource.Name, resource.Content); err != nil { + if err := xfs.WriteFile("ssh://"+resourceFilePath, resource.Content); err != nil { return true, fmt.Errorf("unable to create file at %s: %s", resource.Name, err) } } return true, nil + case TypeFolder: + if resource.Operation == OperationCreate { + // TODO: backup if file exists + if err := xfs.CreateFolder("ssh://" + resourceFilePath); err != nil { + return true, fmt.Errorf("unable to create folder at %s: %s", resource.Name, err) + } + } + return true, nil + case TypeLink: + if resource.Operation == OperationCreate { + args := RemoteRunOpts{Args: []string{"-s " + resource.LinkSource + " " + resourceFilePath}, AllowFail: true} + if resource.EnforceLink { + args = RemoteRunOpts{Args: []string{"-sf " + resource.LinkSource + " " + resourceFilePath}} + } + if _, err := SimpleRemoteRun("ln", args); err != nil { + return true, fmt.Errorf("Unable to symlink " + resource.LinkSource + " -> " + resourceFilePath + ": " + err.Error()) + } + } } // CONTAINER via ResourceGroup (see StackHead container module) return false, nil } func RollbackResourceOperation(resource Resource) (bool, error) { - // FILE - if resource.Type == TypeFile { + switch resource.Type { + case TypeFile: + case TypeLink: if resource.Operation == OperationCreate { // TODO: restore backup if file exists - if err := xfs.DeleteFile("ssh://" + resource.Name); err != nil { + resourcePath, _ := Context.CurrentDeployment.GetResourcePath(resource) + if err := xfs.DeleteFile("ssh://" + resourcePath); err != nil { return true, fmt.Errorf("unable to remove file at %s: %s", resource.Name, err) } } return true, nil + case TypeFolder: + if resource.Operation == OperationCreate { + // TODO: restore backup if file exists + resourcePath, _ := Context.CurrentDeployment.GetResourcePath(resource) + if err := xfs.DeleteFolder("ssh://"+resourcePath, true); err != nil { + return true, fmt.Errorf("unable to remove folder at %s: %s", resource.Name, err) + } + } + return true, nil } // CONTAINER via ResourceGroup (see StackHead container module) return false, nil From 82cce26f91f2633c6b283d5b9c47a40de2c70a8c Mon Sep 17 00:00:00 2001 From: Mario Lubenka Date: Sun, 28 May 2023 19:15:10 +0200 Subject: [PATCH 2/6] feat: folder and Link resource handling --- commands/project/deploy.go | 4 + commands/project/destroy.go | 39 +++--- modules/proxy/nginx/deploy.go | 20 ++-- modules/proxy/nginx/destroy.go | 10 -- routines/implementations.go | 209 +++++++++++++++++++-------------- system/deployments.go | 34 ++++++ system/resource.go | 3 + system/resource_manager.go | 60 ++++++---- 8 files changed, 228 insertions(+), 151 deletions(-) diff --git a/commands/project/deploy.go b/commands/project/deploy.go index 4ad37bf8..714c2913 100644 --- a/commands/project/deploy.go +++ b/commands/project/deploy.go @@ -43,10 +43,14 @@ var DeployApplication = func() *cobra.Command { err = taskRunner.RunTask(routines.PrepareProjectTask(projectDefinition)) if err != nil { + if system.Context.CurrentDeployment.Version > 0 { + _ = xfs.DeleteFolder("ssh://"+system.Context.CurrentDeployment.GetPath(), true) + } return } err = taskRunner.RunTask(routines.CollectResourcesTask(projectDefinition)) if err != nil { + _ = xfs.DeleteFolder("ssh://"+system.Context.CurrentDeployment.GetPath(), true) return } diff --git a/commands/project/destroy.go b/commands/project/destroy.go index fab9f115..fcb71c72 100644 --- a/commands/project/destroy.go +++ b/commands/project/destroy.go @@ -1,8 +1,6 @@ package project import ( - "fmt" - xfs "github.com/saitho/golang-extended-fs/v2" "github.com/spf13/cobra" @@ -12,6 +10,12 @@ import ( "github.com/getstackhead/stackhead/system" ) +func reverse[S ~[]E, E any](s S) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} + // DestroyApplication is a command object for Cobra that provides the destroy command var DestroyApplication = &cobra.Command{ Use: "destroy [path to project definition] [ipv4 address]", @@ -26,22 +30,28 @@ var DestroyApplication = &cobra.Command{ } commands.PrepareContext(args[1], system.ContextActionProjectDeploy, projectDefinition) - modules := system.Context.GetModulesInOrder() - for i, j := 0, len(modules)-1; i < j; i, j = i+1, j-1 { // reverse module list - modules[i], modules[j] = modules[j], modules[i] + latestDeployment, err := system.GetLatestDeployment(projectDefinition) + if err != nil { + panic("unable to load latest deployment" + err.Error()) } + system.Context.CurrentDeployment = *latestDeployment - // Init modules + modules := system.Context.GetModulesInOrder() + reverse(modules) + + // Run modules destroy steps for _, module := range modules { moduleSettings := system.GetModuleSettings(module.GetConfig().Name) module.Init(moduleSettings) } taskRunner := routines.TaskRunner{} - subTasks := []routines.Task{} + subTasks := []routines.Task{ + // Remove resources from deployment + routines.RemoveResources(latestDeployment), + } if hasProjectDir, _ := xfs.HasFolder("ssh://" + projectDefinition.GetDirectoryPath()); hasProjectDir { - // Run destroy scripts from plugins for _, module := range modules { moduleSettings := system.GetModuleSettings(module.GetConfig().Name) @@ -65,13 +75,10 @@ var DestroyApplication = &cobra.Command{ }) } - _ = taskRunner.RunTask(routines.Task{ - Name: fmt.Sprintf("Destroying project \"%s\" on server with IP \"%s\"", args[0], args[1]), - Run: func(r *routines.Task) error { - return nil - }, - SubTasks: subTasks, - //RunAllSubTasksDespiteError: true, - }) + for _, task := range subTasks { + if err = taskRunner.RunTask(task); err != nil { + panic(err) + } + } }, } diff --git a/modules/proxy/nginx/deploy.go b/modules/proxy/nginx/deploy.go index 5c0e74d1..ab7d6a62 100644 --- a/modules/proxy/nginx/deploy.go +++ b/modules/proxy/nginx/deploy.go @@ -117,10 +117,6 @@ func (Module) Deploy(_modulesSettings interface{}) error { fmt.Println("Deploy step") paths := getPaths() - if err := xfs.CreateFolder("ssh://" + paths.CertificatesProjectDirectory); err != nil { - return err - } - serverConfig := buildServerConfig(system.Context.Project, proxy.Context.AllPorts) nginxConfigResource := system.Resource{ Type: system.TypeFile, @@ -132,28 +128,34 @@ func (Module) Deploy(_modulesSettings interface{}) error { system.Context.CurrentDeployment.ResourceGroups = append(system.Context.CurrentDeployment.ResourceGroups, system.ResourceGroup{ Name: "proxy-nginx-" + system.Context.Project.Name, Resources: []system.Resource{ - system.Resource{ + { + Type: system.TypeFolder, + Operation: system.OperationCreate, + Name: paths.CertificatesProjectDirectory, + ExternalResource: true, + }, + { Type: system.TypeFolder, Operation: system.OperationCreate, Name: "certificates", }, nginxConfigResource, // Symlink project certificate files to snakeoil files after initial creation - system.Resource{ + { Type: system.TypeLink, Operation: system.OperationCreate, Name: paths.CertificatesProjectDirectory + "/fullchain.pem", ExternalResource: true, LinkSource: paths.SnakeoilFullchainPath, }, - system.Resource{ + { Type: system.TypeLink, Operation: system.OperationCreate, Name: paths.CertificatesProjectDirectory + "/privkey.pem", ExternalResource: true, LinkSource: paths.SnakeoilPrivkeyPath, }, - system.Resource{ + { Type: system.TypeLink, Operation: system.OperationCreate, Name: "/etc/nginx/sites-available/stackhead_" + system.Context.Project.Name + ".conf", @@ -161,7 +163,7 @@ func (Module) Deploy(_modulesSettings interface{}) error { LinkSource: nginxConfigResourcePath, EnforceLink: true, }, - system.Resource{ + { Type: system.TypeLink, Operation: system.OperationCreate, Name: moduleSettings.Config.VhostPath + "/stackhead_" + system.Context.Project.Name + ".conf", diff --git a/modules/proxy/nginx/destroy.go b/modules/proxy/nginx/destroy.go index 055afb37..9002c627 100644 --- a/modules/proxy/nginx/destroy.go +++ b/modules/proxy/nginx/destroy.go @@ -28,16 +28,6 @@ func (m Module) Destroy(_modulesSettings interface{}) error { return fmt.Errorf("Unable to remove ACME challenge directory: " + err.Error()) } - if err := xfs.DeleteFile("ssh:///etc/nginx/sites-available/stackhead_" + system.Context.Project.Name + ".conf"); err != nil { - return fmt.Errorf("Unable to remove Nginx symlink: " + err.Error()) - } - if err := xfs.DeleteFile("ssh://" + moduleSettings.Config.VhostPath + "/stackhead_" + system.Context.Project.Name + ".conf"); err != nil { - return fmt.Errorf("Unable to remove Nginx symlink: " + err.Error()) - } - if err := xfs.DeleteFolder("ssh://"+CertificatesDirectory+"/"+system.Context.Project.Name, true); err != nil { - return fmt.Errorf("Unable to remove certificates directory: " + err.Error()) - } - if _, err := system.SimpleRemoteRun("systemctl", system.RemoteRunOpts{Args: []string{"reload", "nginx"}, Sudo: true}); err != nil { return fmt.Errorf("Unable to reload Nginx service: " + err.Error()) } diff --git a/routines/implementations.go b/routines/implementations.go index 561219a5..c2f1473d 100644 --- a/routines/implementations.go +++ b/routines/implementations.go @@ -2,14 +2,15 @@ package routines import ( "fmt" + "path" + "strconv" + "strings" + "time" + "github.com/chelnak/ysmrr" xfs "github.com/saitho/golang-extended-fs/v2" logger "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" - "path" - "sort" - "strings" - "time" "github.com/getstackhead/stackhead/project" "github.com/getstackhead/stackhead/system" @@ -37,7 +38,7 @@ var ValidateStackHeadVersionTask = Task{ var PrepareProjectTask = func(projectDefinition *project.Project) Task { return Task{ - Name: fmt.Sprintf("Preparing project structure"), + Name: fmt.Sprintf("Preparing deployment"), Run: func(r *Task) error { r.PrintLn("Create project directory if not exists") if err := xfs.CreateFolder("ssh://" + projectDefinition.GetDirectoryPath()); err != nil { @@ -47,33 +48,17 @@ var PrepareProjectTask = func(projectDefinition *project.Project) Task { return err } + r.PrintLn("Lookup previous deployments") // Find latest deployment - files, err := xfs.ListFolders("ssh://" + projectDefinition.GetDeploymentsPath()) + latestDeployment, err := system.GetLatestDeployment(projectDefinition) if err != nil { return err } - if files != nil { - // newest files at the top - sort.Slice(files, func(i, j int) bool { - return files[i].ModTime().After(files[j].ModTime()) - }) - for _, file := range files { - if file.IsDir() && system.MatchDeploymentNaming(file.Name()) { - fullPath := path.Join(projectDefinition.GetDeploymentsPath(), file.Name()) - latestDeployment, err := system.GetDeploymentByPath(fullPath) - if err != nil { - return err - } - if !latestDeployment.RolledBack { - latestDeployment.Project = system.Context.Project - system.Context.LatestDeployment = latestDeployment - break - } - } - } - } + system.Context.LatestDeployment = latestDeployment + oldVersion := "N/A" newVersion := 1 if system.Context.LatestDeployment != nil { + oldVersion = "v" + strconv.Itoa(system.Context.LatestDeployment.Version) newVersion = system.Context.LatestDeployment.Version + 1 } system.Context.CurrentDeployment = system.Deployment{ @@ -81,6 +66,7 @@ var PrepareProjectTask = func(projectDefinition *project.Project) Task { DateStart: time.Now(), Project: system.Context.Project, } + r.PrintLn(fmt.Sprintf("Previous deployment: %s, new deployment: v%d", oldVersion, newVersion)) // Create folder for new deployment if err := xfs.CreateFolder("ssh://" + system.Context.CurrentDeployment.GetPath()); err != nil { @@ -104,6 +90,7 @@ var CollectResourcesTask = func(projectDefinition *project.Project) Task { if module.GetConfig().Type == "plugin" { continue } + r.PrintLn("Collecting from " + module.GetConfig().Name) moduleSettings := system.GetModuleSettings(module.GetConfig().Name) if err := module.Deploy(moduleSettings); err != nil { return err @@ -125,24 +112,8 @@ var RollbackResources = Task{ } var errors []error for _, resourceGroup := range resourceRollbackOrder { - if resourceGroup.RollbackResourceFunc != nil { - if err := resourceGroup.RollbackResourceFunc(); err != nil { - errors = append(errors, fmt.Errorf("Unable to completely rollback resources: %s", err)) - } - } - for _, resource := range resourceGroup.Resources { - spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(true)) - matched, err := system.RollbackResourceOperation(resource) - if !matched || err == nil { - if spinner != nil { - spinner.Complete() - } - } else if err != nil { - errors = append(errors, fmt.Errorf("Rollback error: %s", err)) - if spinner != nil { - spinner.Error() - } - } + if _, err := processResourceGroup(r.TaskRunner, resourceGroup, true, false); err != nil { + errors = append(errors, fmt.Errorf("Rollback error: %s", err)) } } @@ -156,58 +127,76 @@ var RollbackResources = Task{ return fmt.Errorf("The following errors occurred:\n" + strings.Join(system.Context.CurrentDeployment.RollbackErrors, "\n")) } - // Delete deployment version - //if err := xfs.DeleteFolder("ssh://"+system.Context.CurrentDeployment.GetPath(), true); err != nil { - // return fmt.Errorf("unable to remove deployment folder: " + err.Error()) - //} - return nil }, } +// return: bool: whether to consider resource group for requiring rollback ; error +func processResourceGroup(taskRunner *TaskRunner, resourceGroup system.ResourceGroup, isRollbackMode bool, ignoreBackup bool) (bool, error) { + var uncompletedSpinners []*ysmrr.Spinner + + // ROLLBACK mode + if isRollbackMode && resourceGroup.RollbackResourceFunc != nil { + if err := resourceGroup.RollbackResourceFunc(); err != nil { + return false, err + } + } + + for _, resource := range resourceGroup.Resources { + spinner := taskRunner.GetNewSubtaskSpinner(resource.ToString(isRollbackMode)) + var err error + var processed bool + if isRollbackMode { + processed, err = system.RollbackResourceOperation(resource, ignoreBackup) + } else { + processed, err = system.ApplyResourceOperation(resource, ignoreBackup) + } + if err != nil { + if spinner != nil { + spinner.UpdateMessage(err.Error()) + spinner.Error() + } + return false, err + } + + if spinner != nil { + if processed { + spinner.Complete() + } else { + // uncompleted spinners are resolved when resource group finishes + uncompletedSpinners = append(uncompletedSpinners, spinner) + } + } + } + + // APPLY mode + if !isRollbackMode && resourceGroup.ApplyResourceFunc != nil { + if err := resourceGroup.ApplyResourceFunc(); err != nil { + for _, spinner := range uncompletedSpinners { + spinner.Error() + } + return true, err + } + } + for _, spinner := range uncompletedSpinners { + spinner.Complete() + } + return !isRollbackMode, nil +} + var CreateResources = Task{ Name: "Creating resources", Run: func(r *Task) error { - var errors []error - var uncompletedSpinners []*ysmrr.Spinner - + var errors []string for _, resourceGroup := range system.Context.CurrentDeployment.ResourceGroups { - for _, resource := range resourceGroup.Resources { - spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(false)) - processed, err := system.ApplyResourceOperation(resource) - if err != nil { - rollback = true - errors = append(errors, err) - if spinner != nil { - spinner.UpdateMessage(err.Error()) - spinner.Error() - } - return err - } - - if spinner != nil { - if processed { - spinner.Complete() - } else { - // uncompleted spinners are resolved when resource group finishes - uncompletedSpinners = append(uncompletedSpinners, spinner) - } - } + considerForRollback, err := processResourceGroup(r.TaskRunner, resourceGroup, false, false) + if considerForRollback { + resourceRollbackOrder = append([]system.ResourceGroup{resourceGroup}, resourceRollbackOrder...) } - resourceRollbackOrder = append([]system.ResourceGroup{resourceGroup}, resourceRollbackOrder...) - if resourceGroup.ApplyResourceFunc != nil { - if err := resourceGroup.ApplyResourceFunc(); err != nil { - for _, spinner := range uncompletedSpinners { - spinner.Error() - } - rollback = true - errors = append(errors, fmt.Errorf("Unable to complete resource creation: %s", err)) - } - } - if !rollback { - for _, spinner := range uncompletedSpinners { - spinner.Complete() - } + if err != nil { + rollback = true + errors = append(errors, err.Error()) + break } } if !rollback { @@ -219,24 +208,64 @@ var CreateResources = Task{ } errorMessages := []string{"The following errors occurred:"} for _, err2 := range errors { - errorMessages = append(errorMessages, "- "+err2.Error()) + errorMessages = append(errorMessages, "- "+err2) } return fmt.Errorf(strings.Join(errorMessages, "\n")) }, } +func reverse[S ~[]E, E any](s S) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} + +var RemoveResources = func(latestDeployment *system.Deployment) Task { + return Task{ + Name: "Removing project resources", + Run: func(r *Task) error { + reverse(latestDeployment.ResourceGroups) + for _, group := range latestDeployment.ResourceGroups { + var filteredResources []system.Resource + for _, resource := range group.Resources { + if resource.ExternalResource { + resource.Operation = system.OperationDelete + filteredResources = append(filteredResources, resource) + } + } + reverse(filteredResources) + group.Resources = filteredResources + + if _, err := processResourceGroup(r.TaskRunner, group, false, true); err != nil { + return err + } + } + return nil + }, + } +} + var FinalizeDeployment = Task{ Name: "Finalizing deployment", Run: func(r *Task) error { + // set deployment end date system.Context.CurrentDeployment.DateEnd = time.Now() - resourcesPath := path.Join(system.Context.CurrentDeployment.GetPath(), "deployment.yaml") + + // save deployment.yaml file yamlString, err := yaml.Marshal(system.Context.CurrentDeployment) if err != nil { return err } - if err = xfs.WriteFile("ssh://"+resourcesPath, string(yamlString)); err != nil { + if err = xfs.WriteFile("ssh://"+path.Join(system.Context.CurrentDeployment.GetPath(), "deployment.yaml"), string(yamlString)); err != nil { return err } + + // update current symlink if deployment was successful + if !system.Context.CurrentDeployment.RolledBack { + if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-sfn " + system.Context.CurrentDeployment.GetPath() + " " + path.Join(system.Context.CurrentDeployment.Project.GetDeploymentsPath(), "current")}}); err != nil { + return fmt.Errorf("Unable to symlink current deployment: " + err.Error()) + } + } return nil }, } diff --git a/system/deployments.go b/system/deployments.go index b2b07a52..4a73ca45 100644 --- a/system/deployments.go +++ b/system/deployments.go @@ -2,13 +2,44 @@ package system import ( "fmt" + "path" "regexp" + "sort" xfs "github.com/saitho/golang-extended-fs/v2" "gopkg.in/yaml.v3" "path/filepath" + + "github.com/getstackhead/stackhead/project" ) +func GetLatestDeployment(project *project.Project) (*Deployment, error) { + files, err := xfs.ListFolders("ssh://" + project.GetDeploymentsPath()) + if err != nil { + return nil, err + } + if files != nil { + // newest files at the top + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime().After(files[j].ModTime()) + }) + for _, file := range files { + if file.IsDir() && MatchDeploymentNaming(file.Name()) { + fullPath := path.Join(project.GetDeploymentsPath(), file.Name()) + latestDeployment, err := GetDeploymentByPath(fullPath) + if err != nil { + return nil, err + } + if !latestDeployment.RolledBack { + latestDeployment.Project = project + return latestDeployment, nil + } + } + } + } + return nil, nil +} + func MatchDeploymentNaming(folderName string) bool { pattern := regexp.MustCompile(`(?m)^v(\d+)$`) return pattern.MatchString(folderName) @@ -28,6 +59,9 @@ func GetDeploymentByPath(path string) (*Deployment, error) { return nil, fmt.Errorf("Missing deployment file in folder") } deploymentFile, err := xfs.ReadFile(deploymentFilePath) + if err != nil { + return nil, err + } if err = yaml.Unmarshal([]byte(deploymentFile), &deployment); err != nil { return nil, err } diff --git a/system/resource.go b/system/resource.go index 8e3edff0..cbbbad18 100644 --- a/system/resource.go +++ b/system/resource.go @@ -20,6 +20,7 @@ type Operation string const ( OperationCreate Operation = "create" + OperationDelete Operation = "delete" ) type ApplyResourceFuncType func() error @@ -70,6 +71,8 @@ func (r Resource) GetOperationLabel(invertOperation bool) string { operation = "UPDATE" } } + } else if r.Operation == OperationDelete { + operation = "DELETE" } if invertOperation { diff --git a/system/resource_manager.go b/system/resource_manager.go index 245ec414..1b894522 100644 --- a/system/resource_manager.go +++ b/system/resource_manager.go @@ -2,12 +2,24 @@ package system import ( "fmt" + xfs "github.com/saitho/golang-extended-fs/v2" ) -func ApplyResourceOperation(resource Resource) (bool, error) { - resourceFilePath, _ := Context.CurrentDeployment.GetResourcePath(resource) +func ApplyResourceOperation(resource Resource, ignoreBackup bool) (bool, error) { + return PerformOperation(resource, ignoreBackup) +} + +func RollbackResourceOperation(resource Resource, ignoreBackup bool) (bool, error) { + if resource.Operation == OperationCreate { + resource.Operation = OperationDelete + return PerformOperation(resource, ignoreBackup) + } + return true, fmt.Errorf(fmt.Sprintf("unupported rollback for operation %s", resource.Operation)) +} +func PerformOperation(resource Resource, ignoreBackup bool) (bool, error) { + resourceFilePath, _ := Context.CurrentDeployment.GetResourcePath(resource) switch resource.Type { case TypeFile: if resource.Operation == OperationCreate { @@ -15,6 +27,15 @@ func ApplyResourceOperation(resource Resource) (bool, error) { if err := xfs.WriteFile("ssh://"+resourceFilePath, resource.Content); err != nil { return true, fmt.Errorf("unable to create file at %s: %s", resource.Name, err) } + } else if resource.Operation == OperationDelete { + // TODO: restore backup if file exists + resourcePath, _ := Context.CurrentDeployment.GetResourcePath(resource) + if err := xfs.DeleteFile("ssh://" + resourcePath); err != nil { + if err.Error() == "file does not exist" { + return true, nil + } + return true, fmt.Errorf("unable to remove file at %s: %s", resource.Name, err) + } } return true, nil case TypeFolder: @@ -23,6 +44,11 @@ func ApplyResourceOperation(resource Resource) (bool, error) { if err := xfs.CreateFolder("ssh://" + resourceFilePath); err != nil { return true, fmt.Errorf("unable to create folder at %s: %s", resource.Name, err) } + } else if resource.Operation == OperationDelete { + // TODO: restore backup if file exists + if err := xfs.DeleteFolder("ssh://"+resourceFilePath, true); err != nil { + return true, fmt.Errorf("unable to remove folder at %s: %s", resource.Name, err) + } } return true, nil case TypeLink: @@ -34,33 +60,15 @@ func ApplyResourceOperation(resource Resource) (bool, error) { if _, err := SimpleRemoteRun("ln", args); err != nil { return true, fmt.Errorf("Unable to symlink " + resource.LinkSource + " -> " + resourceFilePath + ": " + err.Error()) } - } - } - // CONTAINER via ResourceGroup (see StackHead container module) - return false, nil -} - -func RollbackResourceOperation(resource Resource) (bool, error) { - switch resource.Type { - case TypeFile: - case TypeLink: - if resource.Operation == OperationCreate { - // TODO: restore backup if file exists - resourcePath, _ := Context.CurrentDeployment.GetResourcePath(resource) - if err := xfs.DeleteFile("ssh://" + resourcePath); err != nil { - return true, fmt.Errorf("unable to remove file at %s: %s", resource.Name, err) - } - } - return true, nil - case TypeFolder: - if resource.Operation == OperationCreate { + } else if resource.Operation == OperationDelete { // TODO: restore backup if file exists - resourcePath, _ := Context.CurrentDeployment.GetResourcePath(resource) - if err := xfs.DeleteFolder("ssh://"+resourcePath, true); err != nil { - return true, fmt.Errorf("unable to remove folder at %s: %s", resource.Name, err) + if err := xfs.DeleteFile("ssh://" + resourceFilePath); err != nil { + if err.Error() == "file does not exist" { + return true, nil + } + return true, fmt.Errorf("unable to remove symlink at %s: %s", resource.Name, err) } } - return true, nil } // CONTAINER via ResourceGroup (see StackHead container module) return false, nil From e14beaca2fdb111c88b8515a070dfcaaa37e3c38 Mon Sep 17 00:00:00 2001 From: Mario Lubenka Date: Sun, 28 May 2023 22:39:20 +0200 Subject: [PATCH 3/6] feat: implement backups for external files during deployment --- go.mod | 2 +- go.sum | 4 +- modules/container/docker/deploy.go | 6 +- modules/proxy/caddy/deploy.go | 2 +- modules/proxy/nginx/deploy.go | 2 +- routines/implementations.go | 13 ++- system/context.go | 4 +- system/resource.go | 5 +- system/resource_manager.go | 142 +++++++++++++++++++++++++---- 9 files changed, 149 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index b75e5c5a..cf5c214f 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/knadh/koanf v1.4.4 github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 github.com/saitho/diff-docker-compose v1.1.3 - github.com/saitho/golang-extended-fs/v2 v2.0.2 + github.com/saitho/golang-extended-fs/v2 v2.1.0 github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 github.com/sirupsen/logrus v1.9.2 github.com/spf13/cast v1.3.1 diff --git a/go.sum b/go.sum index d06fa094..02d5e14f 100644 --- a/go.sum +++ b/go.sum @@ -862,8 +862,8 @@ github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIH github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/saitho/diff-docker-compose v1.1.3 h1:ZKx+F7yMmoAKzI76BFpr7kY+Thd7quk7d/ETug0qNY8= github.com/saitho/diff-docker-compose v1.1.3/go.mod h1:O3V+mwGtlXQ7UERA4+yunV319kyNLwIKeNSw8PODyEU= -github.com/saitho/golang-extended-fs/v2 v2.0.2 h1:QWGOIP4wFTaSODIU54qDCuH1IQXWcjL6irLezJiHqOA= -github.com/saitho/golang-extended-fs/v2 v2.0.2/go.mod h1:aTmESz9Z7Rwbx80zxGWTeuBmW6MLdzYrBZMJOfXsJVw= +github.com/saitho/golang-extended-fs/v2 v2.1.0 h1:j1X3hu5LQRUM0WQzis8Kyh6zZhqCjYQox7Gw1h/ruX0= +github.com/saitho/golang-extended-fs/v2 v2.1.0/go.mod h1:aTmESz9Z7Rwbx80zxGWTeuBmW6MLdzYrBZMJOfXsJVw= github.com/saitho/jsonschema-validator v1.2.0 h1:bWSvTla54F5cziZsxLBR0mlN3gTw9hjrkFuZU55kO+E= github.com/saitho/jsonschema-validator v1.2.0/go.mod h1:W/1Q1xQ+vyYxANlTEpi6hQBEHJPEi4wJmCBkdIehFvQ= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= diff --git a/modules/container/docker/deploy.go b/modules/container/docker/deploy.go index 51b151e5..f18757eb 100644 --- a/modules/container/docker/deploy.go +++ b/modules/container/docker/deploy.go @@ -147,7 +147,7 @@ func (m Module) Deploy(modulesSettings interface{}) error { oldComposeFilePath := "" var remoteComposeObjMap map[string]interface{} if system.Context.LatestDeployment != nil { - dockerComposeFilePathOld, err := system.Context.LatestDeployment.GetResourcePath(dockerComposeResource) + dockerComposeFilePathOld, err := system.Context.LatestDeployment.GetResourcePath(&dockerComposeResource) if err != nil { return err } @@ -166,7 +166,7 @@ func (m Module) Deploy(modulesSettings interface{}) error { if err != nil { return fmt.Errorf("unable to process remote docker-compose.yaml file from previous deployment: " + err.Error()) } - } else if err != nil && err.Error() != "file does not exist" { + } else if err != nil { return fmt.Errorf("Unable to check state of remote docker-compose.yaml from previous deployment: " + err.Error()) } } @@ -190,7 +190,7 @@ func (m Module) Deploy(modulesSettings interface{}) error { } dockerComposeResource.Content = composeFileContent - dockerComposeFilePathNew, _ := system.Context.CurrentDeployment.GetResourcePath(dockerComposeResource) + dockerComposeFilePathNew, _ := system.Context.CurrentDeployment.GetResourcePath(&dockerComposeResource) system.Context.CurrentDeployment.ResourceGroups = append(system.Context.CurrentDeployment.ResourceGroups, system.ResourceGroup{ Name: "container-docker-" + system.Context.Project.Name + "-composefile", diff --git a/modules/proxy/caddy/deploy.go b/modules/proxy/caddy/deploy.go index 2e516a3f..56a2d4b2 100644 --- a/modules/proxy/caddy/deploy.go +++ b/modules/proxy/caddy/deploy.go @@ -24,7 +24,7 @@ func (Module) Deploy(modulesSettings interface{}) error { Content: caddyDirectives, } - caddyFilePath, err := system.Context.CurrentDeployment.GetResourcePath(caddyFileResource) + caddyFilePath, err := system.Context.CurrentDeployment.GetResourcePath(&caddyFileResource) if err != nil { return err } diff --git a/modules/proxy/nginx/deploy.go b/modules/proxy/nginx/deploy.go index ab7d6a62..0c71de7e 100644 --- a/modules/proxy/nginx/deploy.go +++ b/modules/proxy/nginx/deploy.go @@ -124,7 +124,7 @@ func (Module) Deploy(_modulesSettings interface{}) error { Name: "nginx.conf", Content: serverConfig, } - nginxConfigResourcePath, _ := system.Context.CurrentDeployment.GetResourcePath(nginxConfigResource) + nginxConfigResourcePath, _ := system.Context.CurrentDeployment.GetResourcePath(&nginxConfigResource) system.Context.CurrentDeployment.ResourceGroups = append(system.Context.CurrentDeployment.ResourceGroups, system.ResourceGroup{ Name: "proxy-nginx-" + system.Context.Project.Name, Resources: []system.Resource{ diff --git a/routines/implementations.go b/routines/implementations.go index c2f1473d..fe8af2d8 100644 --- a/routines/implementations.go +++ b/routines/implementations.go @@ -147,9 +147,9 @@ func processResourceGroup(taskRunner *TaskRunner, resourceGroup system.ResourceG var err error var processed bool if isRollbackMode { - processed, err = system.RollbackResourceOperation(resource, ignoreBackup) + processed, err = system.RollbackResourceOperation(&resource, ignoreBackup) } else { - processed, err = system.ApplyResourceOperation(resource, ignoreBackup) + processed, err = system.ApplyResourceOperation(&resource, ignoreBackup) } if err != nil { if spinner != nil { @@ -260,8 +260,15 @@ var FinalizeDeployment = Task{ return err } - // update current symlink if deployment was successful if !system.Context.CurrentDeployment.RolledBack { + // Remove external backups + for _, resourceGroup := range system.Context.CurrentDeployment.ResourceGroups { + for _, resource := range resourceGroup.Resources { + fmt.Println(resource.BackupFilePath) // todo: remove + } + } + + // update current symlink if deployment was successful if _, err := system.SimpleRemoteRun("ln", system.RemoteRunOpts{Args: []string{"-sfn " + system.Context.CurrentDeployment.GetPath() + " " + path.Join(system.Context.CurrentDeployment.Project.GetDeploymentsPath(), "current")}}); err != nil { return fmt.Errorf("Unable to symlink current deployment: " + err.Error()) } diff --git a/system/context.go b/system/context.go index 1a6598ed..977b110d 100644 --- a/system/context.go +++ b/system/context.go @@ -43,9 +43,9 @@ type Deployment struct { ResourceGroups []ResourceGroup } -func (d Deployment) GetResourcePath(resource Resource) (string, error) { +func (d Deployment) GetResourcePath(resource *Resource) (string, error) { if resource.Type != TypeFile && resource.Type != TypeFolder && resource.Type != TypeLink { - return "", fmt.Errorf("not a file, folder or link resource") + return "", fmt.Errorf("unsupported resouce type \"%s\". expected file, folder or link", resource.Type) } if resource.ExternalResource { if !path.IsAbs(resource.Name) { diff --git a/system/resource.go b/system/resource.go index cbbbad18..1b04536c 100644 --- a/system/resource.go +++ b/system/resource.go @@ -35,8 +35,9 @@ type ResourceGroup struct { } type Resource struct { - Type Type - Operation Operation `yaml:"-"` + Type Type + Operation Operation `yaml:"-"` + BackupFilePath string `yaml:"-"` // if set the Name refers to an external resource. for files an absolute path is expected ExternalResource bool `yaml:"externalResource,omitempty"` diff --git a/system/resource_manager.go b/system/resource_manager.go index 1b894522..1ad20045 100644 --- a/system/resource_manager.go +++ b/system/resource_manager.go @@ -2,35 +2,147 @@ package system import ( "fmt" + log "github.com/sirupsen/logrus" xfs "github.com/saitho/golang-extended-fs/v2" ) -func ApplyResourceOperation(resource Resource, ignoreBackup bool) (bool, error) { - return PerformOperation(resource, ignoreBackup) +func ApplyResourceOperation(resource *Resource, ignoreBackup bool) (bool, error) { + if !ignoreBackup { + // Backup existing file + backupPath, err := backupResource(resource) + if err != nil { + return true, err + } + fmt.Println(backupPath) + } + return PerformOperation(resource) } -func RollbackResourceOperation(resource Resource, ignoreBackup bool) (bool, error) { +func RollbackResourceOperation(resource *Resource, ignoreBackup bool) (bool, error) { if resource.Operation == OperationCreate { resource.Operation = OperationDelete - return PerformOperation(resource, ignoreBackup) + found, err := PerformOperation(resource) + if err != nil { + return found, err + } + if !ignoreBackup { + // Restore backup + if err = restoreBackup(resource); err != nil { + return found, err + } + } + return found, err } return true, fmt.Errorf(fmt.Sprintf("unupported rollback for operation %s", resource.Operation)) } -func PerformOperation(resource Resource, ignoreBackup bool) (bool, error) { +func backupResource(resource *Resource) (string, error) { + // && resource.Type != TypeLink todo: make it available for symlinks again + // issue with symlinks: cannot stat symlink: permission denied + if resource.Type != TypeFile && resource.Type != TypeFolder { + return "", nil + } + if !resource.ExternalResource { + return "", nil + } + resourceFilePath, err := Context.CurrentDeployment.GetResourcePath(resource) + if err != nil { + return "", err + } + log.Info("Creating backup of resource " + resourceFilePath) + backupFilePath := resourceFilePath + ".bak" + xfsFilePath := "ssh://" + resourceFilePath + switch resource.Type { + case TypeFile: + hasFile, err := xfs.HasFile(xfsFilePath) + if err != nil { + return "", fmt.Errorf("unable to check status of file %s: %s", resourceFilePath, err) + } + if !hasFile { + return "", nil + } + if _, err = SimpleRemoteRun("cp", RemoteRunOpts{Args: []string{resourceFilePath, backupFilePath}}); err != nil { + return backupFilePath, fmt.Errorf("unable to backup file %s: %s", resourceFilePath, err) + } + return backupFilePath, nil + case TypeLink: + hasFile, err := xfs.HasLink(xfsFilePath) + if err != nil { + return "", fmt.Errorf("unable to check status of link %s: %s", resourceFilePath, err) + } + if !hasFile { + return "", nil + } + if _, err = SimpleRemoteRun("cp", RemoteRunOpts{Args: []string{resourceFilePath, backupFilePath}}); err != nil { + return backupFilePath, fmt.Errorf("unable to backup link %s: %s", resourceFilePath, err) + } + return backupFilePath, nil + case TypeFolder: + hasFolder, err := xfs.HasFolder(xfsFilePath) + if err != nil { + return "", fmt.Errorf("unable to check status of folder %s: %s", resourceFilePath, err) + } + if !hasFolder { + return "", nil + } + if _, err = SimpleRemoteRun("cp", RemoteRunOpts{Args: []string{"-R", resourceFilePath, backupFilePath}}); err != nil { + return backupFilePath, fmt.Errorf("unable to backup folder %s: %s", resourceFilePath, err) + } + return backupFilePath, nil + } + return "", fmt.Errorf("unknown backup handler for resource type %s", resource.Type) +} + +func restoreBackup(resource *Resource) error { + if resource.Type != TypeFile && resource.Type != TypeFolder && resource.Type != TypeLink { + return nil + } + if resource.BackupFilePath == "" { + return nil + } resourceFilePath, _ := Context.CurrentDeployment.GetResourcePath(resource) + xfsBackupFilePath := "ssh://" + resource.BackupFilePath + log.Info("Restoring backup of resource " + resourceFilePath) + + switch resource.Type { + case TypeFile, TypeLink: + hasFile, err := xfs.HasFile(xfsBackupFilePath) + if err != nil { + return err + } + if !hasFile { + return fmt.Errorf("backup not found for " + resource.Name) + } + return xfs.CopyFile(xfsBackupFilePath, "ssh://"+resourceFilePath) + case TypeFolder: + backupFileName := "ssh://" + resourceFilePath + ".bak" + hasFolder, err := xfs.HasFolder(backupFileName) + if err != nil { + return err + } + if !hasFolder { + return fmt.Errorf("backup not found for " + resource.Name) + } + if _, err = SimpleRemoteRun("cp", RemoteRunOpts{Args: []string{"-R", backupFileName, resourceFilePath}}); err != nil { + return err + } + return nil + } + return fmt.Errorf("unknown restore backup handler for resource type %s", resource.Type) +} + +func PerformOperation(resource *Resource) (bool, error) { + resourceFilePath, _ := Context.CurrentDeployment.GetResourcePath(resource) + xfsResourceFilePath := "ssh://" + resourceFilePath switch resource.Type { case TypeFile: if resource.Operation == OperationCreate { - // TODO: backup if file exists - if err := xfs.WriteFile("ssh://"+resourceFilePath, resource.Content); err != nil { + if err := xfs.WriteFile(xfsResourceFilePath, resource.Content); err != nil { return true, fmt.Errorf("unable to create file at %s: %s", resource.Name, err) } } else if resource.Operation == OperationDelete { - // TODO: restore backup if file exists - resourcePath, _ := Context.CurrentDeployment.GetResourcePath(resource) - if err := xfs.DeleteFile("ssh://" + resourcePath); err != nil { + if err := xfs.DeleteFile(xfsResourceFilePath); err != nil { if err.Error() == "file does not exist" { return true, nil } @@ -40,13 +152,11 @@ func PerformOperation(resource Resource, ignoreBackup bool) (bool, error) { return true, nil case TypeFolder: if resource.Operation == OperationCreate { - // TODO: backup if file exists - if err := xfs.CreateFolder("ssh://" + resourceFilePath); err != nil { + if err := xfs.CreateFolder(xfsResourceFilePath); err != nil { return true, fmt.Errorf("unable to create folder at %s: %s", resource.Name, err) } } else if resource.Operation == OperationDelete { - // TODO: restore backup if file exists - if err := xfs.DeleteFolder("ssh://"+resourceFilePath, true); err != nil { + if err := xfs.DeleteFolder(xfsResourceFilePath, true); err != nil { return true, fmt.Errorf("unable to remove folder at %s: %s", resource.Name, err) } } @@ -61,14 +171,14 @@ func PerformOperation(resource Resource, ignoreBackup bool) (bool, error) { return true, fmt.Errorf("Unable to symlink " + resource.LinkSource + " -> " + resourceFilePath + ": " + err.Error()) } } else if resource.Operation == OperationDelete { - // TODO: restore backup if file exists - if err := xfs.DeleteFile("ssh://" + resourceFilePath); err != nil { + if err := xfs.DeleteFile(xfsResourceFilePath); err != nil { if err.Error() == "file does not exist" { return true, nil } return true, fmt.Errorf("unable to remove symlink at %s: %s", resource.Name, err) } } + return true, nil } // CONTAINER via ResourceGroup (see StackHead container module) return false, nil From 100ef396572c047ae49033cdce5155b2997a278a Mon Sep 17 00:00:00 2001 From: Mario Lubenka Date: Sun, 28 May 2023 23:33:09 +0200 Subject: [PATCH 4/6] Implement backups for external files during deployment --- system/resource_manager.go | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/system/resource_manager.go b/system/resource_manager.go index 1ad20045..eb49f90f 100644 --- a/system/resource_manager.go +++ b/system/resource_manager.go @@ -54,28 +54,22 @@ func backupResource(resource *Resource) (string, error) { backupFilePath := resourceFilePath + ".bak" xfsFilePath := "ssh://" + resourceFilePath switch resource.Type { - case TypeFile: - hasFile, err := xfs.HasFile(xfsFilePath) - if err != nil { - return "", fmt.Errorf("unable to check status of file %s: %s", resourceFilePath, err) - } - if !hasFile { - return "", nil - } - if _, err = SimpleRemoteRun("cp", RemoteRunOpts{Args: []string{resourceFilePath, backupFilePath}}); err != nil { - return backupFilePath, fmt.Errorf("unable to backup file %s: %s", resourceFilePath, err) + case TypeFile, TypeLink: + var fileFound bool + var err error + if resource.Type == TypeFile { + fileFound, err = xfs.HasFile(xfsFilePath) + } else { + fileFound, err = xfs.HasLink(xfsFilePath) } - return backupFilePath, nil - case TypeLink: - hasFile, err := xfs.HasLink(xfsFilePath) if err != nil { - return "", fmt.Errorf("unable to check status of link %s: %s", resourceFilePath, err) + return "", fmt.Errorf("unable to check status of %s %s: %s", resource.Type, resourceFilePath, err) } - if !hasFile { + if !fileFound { return "", nil } if _, err = SimpleRemoteRun("cp", RemoteRunOpts{Args: []string{resourceFilePath, backupFilePath}}); err != nil { - return backupFilePath, fmt.Errorf("unable to backup link %s: %s", resourceFilePath, err) + return backupFilePath, fmt.Errorf("unable to backup %s %s: %s", resource.Type, resourceFilePath, err) } return backupFilePath, nil case TypeFolder: From 7314c3a4c2401ecfb5962f025c833a18c0f457f8 Mon Sep 17 00:00:00 2001 From: Mario Lubenka Date: Sun, 18 Jun 2023 14:38:32 +0200 Subject: [PATCH 5/6] fix(docker): logout to avoid authentication issues for multiple credentials per registry --- modules/container/docker/README.md | 1 + modules/container/docker/deploy.go | 7 +++++++ modules/container/docker/system/hooks.go | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/container/docker/README.md b/modules/container/docker/README.md index 109a17c9..9ebdcefc 100644 --- a/modules/container/docker/README.md +++ b/modules/container/docker/README.md @@ -20,6 +20,7 @@ Setting up the server with this plugin will install the following: * Docker Compose Plugin * containerd.io * Pass (for registry credential storage) + * **Note:** Right now defining multiple credentials for the same registry results in authentication issues. That is why the registry credentials are only available during deployment and not persisted on the target server. * golang-docker-credential-helpers ## Configuration diff --git a/modules/container/docker/deploy.go b/modules/container/docker/deploy.go index f18757eb..1d737c5c 100644 --- a/modules/container/docker/deploy.go +++ b/modules/container/docker/deploy.go @@ -273,6 +273,13 @@ func prepareUpdate(result diff_docker_compose.YamlDiffResult) (bool, error) { return false, err } } + // Logout to clear credentials, as multiple credentials for the same registry cause issues... + // todo: find a better way to solve that issue + defer func() { + for _, registry := range system.Context.Project.Container.Registries { + _, _ = system.SimpleRemoteRun("docker", system.RemoteRunOpts{Args: []string{"logout", registry.Url}}) + } + }() updatedImages := false changedServices := result.GetStructure([]string{"services"}) diff --git a/modules/container/docker/system/hooks.go b/modules/container/docker/system/hooks.go index 3b71ff83..f2a6cc44 100644 --- a/modules/container/docker/system/hooks.go +++ b/modules/container/docker/system/hooks.go @@ -79,7 +79,7 @@ func ExecuteHook(hookName string) error { WorkingDir: system.Context.CurrentDeployment.GetPath(), }) if err != nil { - return fmt.Errorf("Unable to run %s on container %s: \"%s\"", file, containerName, err.Error()) + return fmt.Errorf("Unable to run %s on container %s: \"%s\"", containerLocation, containerName, err.Error()) } } From 9e1e1109addc7e7891ffa02d05910b3cb489bd6d Mon Sep 17 00:00:00 2001 From: Mario Lubenka Date: Sun, 18 Jun 2023 14:38:53 +0200 Subject: [PATCH 6/6] docs: stackhead user debugging --- Documentation/SUMMARY.md | 1 - Documentation/development/basics.md | 21 +++++++++++++++++-- Documentation/introduction/getting-started.md | 2 +- .../introduction/project-definition.md | 20 +++++++++--------- .../stackhead-modules/development/README.md | 2 -- 5 files changed, 30 insertions(+), 16 deletions(-) delete mode 100644 Documentation/stackhead-modules/development/README.md diff --git a/Documentation/SUMMARY.md b/Documentation/SUMMARY.md index 463921a5..2d3f66fb 100644 --- a/Documentation/SUMMARY.md +++ b/Documentation/SUMMARY.md @@ -19,7 +19,6 @@ * [About modules](stackhead-modules/stackhead-modules.md) * [List of modules](stackhead-modules/list-of-modules.md) -* [Development](stackhead-modules/development/README.md) ## Technical Documentation diff --git a/Documentation/development/basics.md b/Documentation/development/basics.md index 6f14511f..060468e1 100644 --- a/Documentation/development/basics.md +++ b/Documentation/development/basics.md @@ -14,10 +14,10 @@ When developing for StackHead we encourage you to test with an actual remote ser We recommend a basic Ubuntu server on [Hetzner Cloud](https://hetzner.cloud/?ref=n7H3qhWcZ2QS). Right now the cheapest option comes in at 4,15€ per month (3,56€ Server + 0,60€ IPv4). -However it is charged per-use. So you'll only paying the time the server is actually running. +However, it is charged per-use. So you'll only paying the time the server is actually running. So you should be paying only a few cents (or even nothing) when running it for a few hours while testing. -Make sure to setup the server with SSH key access, so you can connect to it from your local PC with root user. +Make sure to set up the server with SSH key access, so you can connect to it from your local PC with root user. Verify you can connect to it via `ssh root@[IPv4 address]`. Then, set the A record of an actual domain or subdomain to the IP address. @@ -27,3 +27,20 @@ Setup server: Deploy project: `./bin/stackhead-cli project deploy my_file.stackhead.yml [IPv4 address]` + +## Debugging + +### Debugging stackhead user + +If you want to connect to a server with the `stackhead` user, set its private key in your SSH config: + +```shell +Host [IP_Address] + User stackhead + IdentityFile ~/.config/getstackhead/stackhead/ssh/remotes/[IP_Address]/private_key.pem +``` + +{% hint style="warning" %} +Docker Registry credentials are only available during deployment. If you need to manually pull Docker images +with the stackhead user, you'll need to authenticate against your Docker Registry again. +{% endhint %} diff --git a/Documentation/introduction/getting-started.md b/Documentation/introduction/getting-started.md index 37ef8080..8c8fad3e 100644 --- a/Documentation/introduction/getting-started.md +++ b/Documentation/introduction/getting-started.md @@ -16,7 +16,7 @@ If you wish to change the software used for proxy or containers, please [create ## Creating a project definition -Create a new project definitions file at `./stackhead/example_app.yml` and the following content: +Create a new project definitions file at `./stackhead/example_app.stackhead.yml` and the following content: ```yaml --- diff --git a/Documentation/introduction/project-definition.md b/Documentation/introduction/project-definition.md index f7010400..0c560637 100644 --- a/Documentation/introduction/project-definition.md +++ b/Documentation/introduction/project-definition.md @@ -72,7 +72,7 @@ container: ## Settings -### `domains.\*.expose` +### `domains.[*].expose` The web server will proxy all web traffic to the service and port specified in `expose` setting. @@ -82,20 +82,20 @@ In the example above, the web server will proxy web requests to the "app" contai Name of the Container service to receive the web request. -#### `internal\_port` +#### `internal_port` Port of the given container service to receive the web request. -#### `external\_port` +#### `external_port` Port that Nginx listens to. {% hint style="danger" %} -Setting _external\_port_ to 443 is not allowed, as HTTPS forwarding is automatically enabled for exposes with `external_port=80`. +Setting _external_port_ to 443 is not allowed, as HTTPS forwarding is automatically enabled for exposes with `external_port=80`. {% endhint %} {% hint style="warning" %} -Make sure to define the different _external\_port_ within one project definition, so that each port is only used once! +Make sure to define the different _external_port_ within one project definition, so that each port is only used once! {% endhint %} #### `proxy_websocket_locations` @@ -107,7 +107,7 @@ Please do not set `/` or `/.well-known/acme-challenge` as WebSocket locations. {% endhint %} -### `domains.\*.dns` +### `domains.[*].dns` Configure which DNS service to use for this domain. Make sure to install the required module and set the required configurations according to the DNS StackHead module's documentation. @@ -116,7 +116,7 @@ Make sure to install the required module and set the required configurations acc Fully-qualified name of the provider to use (e.g. `getstackhead.stackhead_dns_cloudflare`). -### `domains.\*.security` +### `domains.[*].security` These options can be used to add further security to your projects. @@ -205,7 +205,7 @@ Below you can see a comparison of the project definition \(left\) and the equiva {% tabs %} {% tab title="StackHead" %} -{% code title="example\_project.yml" %} +{% code title="example_project.stackhead.yml" %} ```yaml services: - name: nginx @@ -240,9 +240,9 @@ services: {% endtab %} {% endtabs %} -#### `volumes\_from` +#### `volumes_from` -See [docker-compose documentation on volumes\_from](https://docs.docker.com/compose/compose-file/compose-file-v2/#volumes_from). +See [docker-compose documentation on volumes_from](https://docs.docker.com/compose/compose-file/compose-file-v2/#volumes_from). #### `environment` diff --git a/Documentation/stackhead-modules/development/README.md b/Documentation/stackhead-modules/development/README.md deleted file mode 100644 index a22ceedc..00000000 --- a/Documentation/stackhead-modules/development/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# Development -