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 - diff --git a/commands/project/deploy.go b/commands/project/deploy.go index 2b03473a..714c2913 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" @@ -42,19 +43,23 @@ 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 } 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 +67,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/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/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/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/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..1d737c5c 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 { + 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 }, @@ -246,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/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..f2a6cc44 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,10 +76,10 @@ 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()) + return fmt.Errorf("Unable to run %s on container %s: \"%s\"", containerLocation, 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..56a2d4b2 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..0c71de7e 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,81 @@ 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{ + { + 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 + { + Type: system.TypeLink, + Operation: system.OperationCreate, + Name: paths.CertificatesProjectDirectory + "/fullchain.pem", + ExternalResource: true, + LinkSource: paths.SnakeoilFullchainPath, + }, + { + Type: system.TypeLink, + Operation: system.OperationCreate, + Name: paths.CertificatesProjectDirectory + "/privkey.pem", + ExternalResource: true, + LinkSource: paths.SnakeoilPrivkeyPath, + }, + { + Type: system.TypeLink, + Operation: system.OperationCreate, + Name: "/etc/nginx/sites-available/stackhead_" + system.Context.Project.Name + ".conf", + ExternalResource: true, + LinkSource: nginxConfigResourcePath, + EnforceLink: true, + }, + { + 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/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/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..fe8af2d8 100644 --- a/routines/implementations.go +++ b/routines/implementations.go @@ -2,11 +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" "github.com/getstackhead/stackhead/project" "github.com/getstackhead/stackhead/system" @@ -34,12 +38,40 @@ 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 { return err } + if err := xfs.CreateFolder("ssh://" + projectDefinition.GetDeploymentsPath()); err != nil { + return err + } + + r.PrintLn("Lookup previous deployments") + // Find latest deployment + latestDeployment, err := system.GetLatestDeployment(projectDefinition) + if err != nil { + return err + } + 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{ + Version: newVersion, + 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 { + return err + } return nil }, ErrorAsErrorMessage: true, @@ -58,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 @@ -79,76 +112,91 @@ 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)) - } + if _, err := processResourceGroup(r.TaskRunner, resourceGroup, true, false); err != nil { + errors = append(errors, fmt.Errorf("Rollback error: %s", err)) } - for _, resource := range resourceGroup.Resources { - spinner := r.TaskRunner.GetNewSubtaskSpinner(resource.ToString(true)) - matched, err := system.RollbackResourceOperation(resource) - if !matched || err == nil { - spinner.Complete() - } else if err != nil { - errors = append(errors, fmt.Errorf("Rollback error: %s", err)) - 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")) + } + + 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 - - for _, resourceGroup := range system.Context.Resources { - 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) - } - } + var errors []string + for _, resourceGroup := range system.Context.CurrentDeployment.ResourceGroups { + 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 { @@ -160,8 +208,71 @@ 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() + + // save deployment.yaml file + yamlString, err := yaml.Marshal(system.Context.CurrentDeployment) + if err != nil { + return err + } + if err = xfs.WriteFile("ssh://"+path.Join(system.Context.CurrentDeployment.GetPath(), "deployment.yaml"), string(yamlString)); err != nil { + return err + } + + 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()) + } + } + return nil + }, +} diff --git a/system/context.go b/system/context.go index fbb97525..977b110d 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("unsupported resouce type \"%s\". expected file, folder or link", resource.Type) + } + 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..4a73ca45 --- /dev/null +++ b/system/deployments.go @@ -0,0 +1,69 @@ +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) +} + +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 != nil { + return nil, err + } + 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..1b04536c 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" ) @@ -18,6 +20,7 @@ type Operation string const ( OperationCreate Operation = "create" + OperationDelete Operation = "delete" ) type ApplyResourceFuncType func() error @@ -27,26 +30,35 @@ type ResourceGroup struct { Name string Resources []Resource - ApplyResourceFunc ApplyResourceFuncType - RollbackResourceFunc RollbackResourceFuncType + ApplyResourceFunc ApplyResourceFuncType `yaml:"-"` + RollbackResourceFunc RollbackResourceFuncType `yaml:"-"` } type Resource struct { - Type Type - Operation Operation + 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"` // 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 { @@ -60,6 +72,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 4344f2fd..eb49f90f 100644 --- a/system/resource_manager.go +++ b/system/resource_manager.go @@ -2,35 +2,177 @@ package system import ( "fmt" + log "github.com/sirupsen/logrus" xfs "github.com/saitho/golang-extended-fs/v2" ) -func ApplyResourceOperation(resource Resource) (bool, error) { - // FILE - if resource.Type == TypeFile { - if resource.Operation == OperationCreate { - // TODO: backup if file exists - if err := xfs.WriteFile("ssh://"+resource.Name, resource.Content); err != nil { - return true, fmt.Errorf("unable to create file at %s: %s", resource.Name, err) +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) { + if resource.Operation == OperationCreate { + resource.Operation = OperationDelete + found, err := PerformOperation(resource) + if err != nil { + return found, err + } + if !ignoreBackup { + // Restore backup + if err = restoreBackup(resource); err != nil { + return found, err } } - return true, nil + return found, err } - // CONTAINER via ResourceGroup (see StackHead container module) - return false, nil + return true, fmt.Errorf(fmt.Sprintf("unupported rollback for operation %s", resource.Operation)) +} + +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, TypeLink: + var fileFound bool + var err error + if resource.Type == TypeFile { + fileFound, err = xfs.HasFile(xfsFilePath) + } else { + fileFound, err = xfs.HasLink(xfsFilePath) + } + if err != nil { + return "", fmt.Errorf("unable to check status of %s %s: %s", resource.Type, resourceFilePath, err) + } + if !fileFound { + return "", nil + } + if _, err = SimpleRemoteRun("cp", RemoteRunOpts{Args: []string{resourceFilePath, backupFilePath}}); err != nil { + return backupFilePath, fmt.Errorf("unable to backup %s %s: %s", resource.Type, 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 RollbackResourceOperation(resource Resource) (bool, error) { - // FILE - if resource.Type == TypeFile { +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: restore backup if file exists - if err := xfs.DeleteFile("ssh://" + resource.Name); 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 { + if err := xfs.DeleteFile(xfsResourceFilePath); 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: + if resource.Operation == OperationCreate { + 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 { + if err := xfs.DeleteFolder(xfsResourceFilePath, true); err != nil { + return true, fmt.Errorf("unable to remove 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()) + } + } else if resource.Operation == OperationDelete { + 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