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..44e0a0fd 100644 --- a/routines/implementations.go +++ b/routines/implementations.go @@ -7,7 +7,7 @@ import ( logger "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" "path" - "sort" + "strconv" "strings" "time" @@ -37,7 +37,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 +47,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 +65,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 +89,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 @@ -132,7 +118,7 @@ var RollbackResources = Task{ } for _, resource := range resourceGroup.Resources { spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(true)) - matched, err := system.RollbackResourceOperation(resource) + matched, err := system.RollbackResourceOperation(resource, false) if !matched || err == nil { if spinner != nil { spinner.Complete() @@ -168,16 +154,16 @@ var RollbackResources = Task{ var CreateResources = Task{ Name: "Creating resources", Run: func(r *Task) error { - var errors []error + var errors []string var uncompletedSpinners []*ysmrr.Spinner for _, resourceGroup := range system.Context.CurrentDeployment.ResourceGroups { for _, resource := range resourceGroup.Resources { spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(false)) - processed, err := system.ApplyResourceOperation(resource) + processed, err := system.ApplyResourceOperation(resource, false) if err != nil { rollback = true - errors = append(errors, err) + errors = append(errors, err.Error()) if spinner != nil { spinner.UpdateMessage(err.Error()) spinner.Error() @@ -201,7 +187,7 @@ var CreateResources = Task{ spinner.Error() } rollback = true - errors = append(errors, fmt.Errorf("Unable to complete resource creation: %s", err)) + errors = append(errors, fmt.Sprintf("Unable to complete resource creation: %s", err)) } } if !rollback { @@ -219,24 +205,89 @@ 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 { + var errors []string + var uncompletedSpinners []*ysmrr.Spinner + + reverse(latestDeployment.ResourceGroups) + for _, group := range latestDeployment.ResourceGroups { + reverse(group.Resources) + for _, resource := range group.Resources { + if resource.ExternalResource { + resource.Operation = system.OperationDelete + spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(false)) + if processed, err := system.PerformOperation(resource, true); err != nil { + if err != nil { + errors = append(errors, err.Error()) + 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) + } + } + } + } + } + for _, spinner := range uncompletedSpinners { + spinner.Complete() + } + } + if len(errors) == 0 { + return nil + } + errorMessages := []string{"The following errors occurred:"} + for _, err2 := range errors { + errorMessages = append(errorMessages, "- "+err2) + } + return fmt.Errorf(strings.Join(errorMessages, "\n")) + }, + } +} + 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