├── .envrc ├── .gitignore ├── devenv.nix ├── devenv.yaml ├── internal ├── provider │ ├── provider.go │ └── git │ │ └── git.go ├── lock │ └── lock.go ├── hook │ └── hook.go ├── logger │ └── logger.go ├── config │ └── config.go ├── utils │ └── utils.go ├── release │ └── release.go └── deployer │ └── deployer.go ├── go.mod ├── .goreleaser.yaml ├── LICENSE ├── Taskfile.yml ├── .github └── workflows │ └── ci.yaml ├── devenv.lock ├── cmd └── deploy │ ├── main.go │ └── main_test.go ├── go.sum └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" 2 | 3 | use devenv 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Devenv 2 | .devenv* 3 | devenv.local.nix 4 | 5 | # direnv 6 | .direnv 7 | 8 | # pre-commit 9 | .pre-commit-config.yaml 10 | 11 | # Test directory 12 | cmd/deploy/testdata/ 13 | 14 | dist/ 15 | main 16 | deploy 17 | deploy-* 18 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { pkgs, lib, config, inputs, ... }: 2 | 3 | { 4 | packages = [ 5 | pkgs.git 6 | pkgs.go-task 7 | pkgs.golangci-lint 8 | pkgs.goreleaser 9 | pkgs.svu 10 | ]; 11 | 12 | languages.go.enable = true; 13 | } 14 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://devenv.sh/devenv.schema.json 2 | inputs: 3 | nixpkgs: 4 | url: github:cachix/devenv-nixpkgs/rolling 5 | 6 | # If you're using non-OSS software, you can set allowUnfree to true. 7 | # allowUnfree: true 8 | 9 | # If you're willing to use a package that's vulnerable 10 | # permittedInsecurePackages: 11 | # - "openssl-1.1.1w" 12 | 13 | # If you have more than one devenv you can merge them 14 | #imports: 15 | # - ./backend 16 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/serversfordev/deploy/internal/config" 7 | "github.com/serversfordev/deploy/internal/provider/git" 8 | ) 9 | 10 | type Provider interface { 11 | Init() error 12 | GetRevision() (string, error) 13 | Clone(targetDir string) error 14 | } 15 | 16 | func New(cfg *config.Config, appDir string) (Provider, error) { 17 | switch cfg.Source.Provider { 18 | case "git": 19 | return git.New(cfg, appDir), nil 20 | default: 21 | return nil, fmt.Errorf("unknown provider type: %s", cfg.Source.Provider) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/lock/lock.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | const lockFileName = "deploy.lock" 10 | 11 | // Acquire creates a lock file to prevent concurrent deployments. 12 | // Returns an error if the lock file already exists or cannot be created. 13 | func Acquire(appDir string) error { 14 | lockFile := filepath.Join(appDir, lockFileName) 15 | if _, err := os.Stat(lockFile); err == nil { 16 | return fmt.Errorf("deployment already in progress, lock file exists") 17 | } 18 | return os.WriteFile(lockFile, []byte{}, 0644) 19 | } 20 | 21 | // Release removes the lock file. 22 | // Returns an error if the lock file cannot be removed. 23 | func Release(appDir string) error { 24 | return os.Remove(filepath.Join(appDir, lockFileName)) 25 | } 26 | -------------------------------------------------------------------------------- /internal/hook/hook.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | ) 9 | 10 | type Hook string 11 | 12 | const ( 13 | HookClone Hook = "clone" 14 | HookBuild Hook = "build" 15 | HookDeploy Hook = "deploy" 16 | HookPostDeploy Hook = "post_deploy" 17 | HookVerify Hook = "verify" 18 | ) 19 | 20 | func ExecuteHook(releaseDir string, hook Hook) error { 21 | hookPath := filepath.Join(releaseDir, ".deploy", "hooks", string(hook)) 22 | 23 | if _, err := os.Stat(hookPath); os.IsNotExist(err) { 24 | return nil 25 | } 26 | 27 | cmd := exec.Command(hookPath) 28 | cmd.Dir = releaseDir 29 | cmd.Stdout = os.Stdout 30 | cmd.Stderr = os.Stderr 31 | 32 | if err := cmd.Run(); err != nil { 33 | return fmt.Errorf("failed to execute hook %s: %w", hook, err) 34 | } 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/serversfordev/deploy 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/onsi/ginkgo/v2 v2.22.2 8 | github.com/onsi/gomega v1.36.2 9 | github.com/pelletier/go-toml/v2 v2.2.3 10 | github.com/urfave/cli/v2 v2.27.5 11 | ) 12 | 13 | require ( 14 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 15 | github.com/go-logr/logr v1.4.2 // indirect 16 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 17 | github.com/google/go-cmp v0.6.0 // indirect 18 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 19 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 20 | github.com/stretchr/testify v1.10.0 // indirect 21 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 22 | golang.org/x/net v0.33.0 // indirect 23 | golang.org/x/sys v0.28.0 // indirect 24 | golang.org/x/text v0.21.0 // indirect 25 | golang.org/x/tools v0.28.0 // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - main: ./cmd/deploy 9 | env: 10 | - CGO_ENABLED=0 11 | ldflags: 12 | - -s -w 13 | - -X main.version={{.Version}} 14 | - -X main.commit={{.Commit}} 15 | - -X main.date={{.Date}} 16 | goos: 17 | - linux 18 | goarch: 19 | - amd64 20 | - arm64 21 | binary: deploy 22 | 23 | archives: 24 | - format: binary 25 | strip_binary_directory: true 26 | name_template: "{{ .Binary }}-{{ .Os }}-{{ .Arch }}" 27 | 28 | checksum: 29 | name_template: 'checksums.txt' 30 | 31 | release: 32 | prerelease: auto 33 | draft: true 34 | 35 | snapshot: 36 | version_template: "{{ incpatch .Version }}-next" 37 | 38 | changelog: 39 | sort: asc 40 | filters: 41 | exclude: 42 | - "^chore:" 43 | - "^docs:" 44 | - "^test:" 45 | - "^ci:" 46 | - "^build:" 47 | - "^revert:" 48 | - "^style:" 49 | - "^refactor:" 50 | - "^perf:" 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Zsolt Kacsándi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | vars: 4 | TEST_FLAGS: -race -failfast -v 5 | 6 | tasks: 7 | check: 8 | desc: Run fmt and code quality checks 9 | cmds: 10 | - task: fmt 11 | - task: vet 12 | - task: lint 13 | - task: test 14 | 15 | check:fmt: 16 | desc: Format all Go files 17 | cmds: 18 | - go fmt ./... 19 | 20 | check:vet: 21 | desc: Run go vet on all packages 22 | cmds: 23 | - go vet ./... 24 | 25 | check:lint: 26 | desc: Run golangci-lint 27 | cmds: 28 | - golangci-lint run 29 | 30 | check:test: 31 | desc: Run tests 32 | cmds: 33 | - go test {{.TEST_FLAGS}} ./... 34 | 35 | release:test: 36 | desc: Test the release 37 | cmds: 38 | - goreleaser release --snapshot --clean 39 | 40 | release:version: 41 | desc: Show current version 42 | cmds: 43 | - svu current 44 | 45 | release:patch: 46 | desc: Bump patch version 47 | cmds: 48 | - svu patch 49 | 50 | release:minor: 51 | desc: Bump minor version 52 | cmds: 53 | - svu minor 54 | 55 | release:major: 56 | desc: Bump major version 57 | cmds: 58 | - svu major 59 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | ) 10 | 11 | type Logger struct { 12 | writers []io.Writer 13 | } 14 | 15 | func New(appDir string) (*Logger, error) { 16 | // Create log file with current date 17 | currentTime := time.Now() 18 | logFileName := fmt.Sprintf("deploy-%s.log", currentTime.Format("2006-01-02")) 19 | logFile, err := os.OpenFile( 20 | filepath.Join(appDir, "logs", logFileName), 21 | os.O_CREATE|os.O_WRONLY|os.O_APPEND, 22 | 0644, 23 | ) 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to create log file: %w", err) 26 | } 27 | 28 | return &Logger{ 29 | writers: []io.Writer{os.Stdout, logFile}, 30 | }, nil 31 | } 32 | 33 | func (l *Logger) Write(p []byte) (n int, err error) { 34 | for _, w := range l.writers { 35 | n, err = w.Write(p) 36 | if err != nil { 37 | return n, err 38 | } 39 | } 40 | return len(p), nil 41 | } 42 | 43 | func (l *Logger) Printf(format string, v ...interface{}) { 44 | message := fmt.Sprintf(format+"\n", v...) 45 | timestamp := time.Now().Format("2006-01-02 15:04:05") 46 | formattedMessage := fmt.Sprintf("[%s] %s", timestamp, message) 47 | _, _ = l.Write([]byte(formattedMessage)) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - 'v*' 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | permissions: 15 | contents: write 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | env: 21 | ENVIRONMENT: CI 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: '1.23' 30 | cache: true 31 | 32 | - name: Run tests 33 | run: | 34 | go test -v ./cmd/deploy/... 35 | 36 | build-and-release: 37 | needs: test 38 | if: startsWith(github.ref, 'refs/tags/v') 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | 44 | - name: Set up Go 45 | uses: actions/setup-go@v5 46 | with: 47 | go-version: '1.23' 48 | cache: true 49 | 50 | - name: Run goreleaser 51 | uses: goreleaser/goreleaser-action@v6 52 | with: 53 | version: latest 54 | args: release --clean 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | toml "github.com/pelletier/go-toml/v2" 8 | ) 9 | 10 | type Config struct { 11 | Source SourceConfig `toml:"source"` 12 | Deploy DeployConfig `toml:"deploy"` 13 | } 14 | 15 | type SourceConfig struct { 16 | Provider string `toml:"provider"` 17 | Git GitConfig `toml:"git,omitempty"` 18 | } 19 | 20 | type GitConfig struct { 21 | Repo string `toml:"repo"` 22 | Branch string `toml:"branch"` 23 | } 24 | 25 | type DeployConfig struct { 26 | KeepReleases int `toml:"keep_releases"` 27 | Jitter JitterConfig `toml:"jitter"` 28 | Shared SharedConfig `toml:"shared"` 29 | } 30 | 31 | type JitterConfig struct { 32 | Min int `toml:"min"` 33 | Max int `toml:"max"` 34 | } 35 | 36 | type SharedConfig struct { 37 | Dirs []string `toml:"dirs"` 38 | Files []string `toml:"files"` 39 | } 40 | 41 | func Default() *Config { 42 | c := &Config{} 43 | 44 | c.Source.Provider = "git" 45 | c.Source.Git.Repo = "" 46 | c.Source.Git.Branch = "main" 47 | 48 | c.Deploy.KeepReleases = 3 49 | c.Deploy.Jitter.Min = 5 50 | c.Deploy.Jitter.Max = 10 51 | c.Deploy.Shared.Dirs = []string{} 52 | c.Deploy.Shared.Files = []string{} 53 | 54 | return c 55 | } 56 | 57 | func Load(path string) (*Config, error) { 58 | data, err := os.ReadFile(path) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to read config file: %w", err) 61 | } 62 | 63 | var cfg Config 64 | if err := toml.Unmarshal(data, &cfg); err != nil { 65 | return nil, fmt.Errorf("failed to parse config file: %w", err) 66 | } 67 | 68 | return &cfg, nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "unicode" 9 | 10 | "github.com/BurntSushi/toml" 11 | 12 | "github.com/serversfordev/deploy/internal/config" 13 | ) 14 | 15 | // NormalizeAppName sanitizes the input string to contain only letters, digits, 16 | // underscores, hyphens, and dots, removing any other characters. 17 | func NormalizeAppName(input string) string { 18 | var builder strings.Builder 19 | 20 | for _, r := range input { 21 | if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' { 22 | builder.WriteRune(r) 23 | } 24 | } 25 | 26 | return strings.TrimSpace(builder.String()) 27 | } 28 | 29 | // InitializeAppStructure creates the necessary directory structure and config file for a new application. 30 | // It returns the path to the created base directory and any error encountered. 31 | func InitializeAppStructure(appName string) (string, error) { 32 | currentDir, err := os.Getwd() 33 | if err != nil { 34 | return "", fmt.Errorf("failed to get current directory: %w", err) 35 | } 36 | baseDir := filepath.Join(currentDir, appName) 37 | dirs := []string{ 38 | filepath.Join(baseDir, "releases"), 39 | filepath.Join(baseDir, "shared"), 40 | filepath.Join(baseDir, "logs"), 41 | } 42 | 43 | for _, dir := range dirs { 44 | if err := os.MkdirAll(dir, 0755); err != nil { 45 | return "", fmt.Errorf("failed to create directory %s: %w", dir, err) 46 | } 47 | } 48 | 49 | cfg := config.Default() 50 | var tomlBuffer strings.Builder 51 | if err := toml.NewEncoder(&tomlBuffer).Encode(cfg); err != nil { 52 | return "", fmt.Errorf("failed to marshal config: %w", err) 53 | } 54 | 55 | tomlPath := filepath.Join(baseDir, "config.toml") 56 | if err := os.WriteFile(tomlPath, []byte(tomlBuffer.String()), 0644); err != nil { 57 | return "", fmt.Errorf("failed to write config file: %w", err) 58 | } 59 | 60 | return baseDir, nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/provider/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/serversfordev/deploy/internal/config" 11 | ) 12 | 13 | type GitProvider struct { 14 | config *config.Config 15 | appDir string 16 | } 17 | 18 | func New(config *config.Config, appDir string) *GitProvider { 19 | return &GitProvider{ 20 | config: config, 21 | appDir: appDir, 22 | } 23 | } 24 | 25 | func (p *GitProvider) Init() error { 26 | _, err := exec.LookPath("git") 27 | if err != nil { 28 | return fmt.Errorf("git executable not found in PATH: %w", err) 29 | } 30 | 31 | if _, err := execGitCommand(p.sourcePath(), "rev-parse", "--git-dir"); err != nil { 32 | args := []string{"clone", p.config.Source.Git.Repo, p.sourcePath()} 33 | if _, err := execGitCommand(p.appDir, args...); err != nil { 34 | return fmt.Errorf("failed to clone repository: %w", err) 35 | } 36 | } 37 | 38 | args := []string{"checkout", p.config.Source.Git.Branch} 39 | if _, err := execGitCommand(p.sourcePath(), args...); err != nil { 40 | return fmt.Errorf("failed to checkout branch: %w", err) 41 | } 42 | 43 | args = []string{"pull", "origin", p.config.Source.Git.Branch} 44 | if _, err := execGitCommand(p.sourcePath(), args...); err != nil { 45 | return fmt.Errorf("failed to pull latest changes: %w", err) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (p *GitProvider) Clone(targetDir string) error { 52 | // Use checkout-index to copy files into target directory 53 | args := []string{"checkout-index", "--prefix=" + targetDir + "/", "-a", "-f"} 54 | _, err := execGitCommand(p.sourcePath(), args...) 55 | if err != nil { 56 | return fmt.Errorf("failed to copy repository files: %w", err) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func (p *GitProvider) GetRevision() (string, error) { 63 | currentHash, err := execGitCommand(p.sourcePath(), "rev-parse", "HEAD") 64 | if err != nil { 65 | return "", err 66 | } 67 | currentHash = strings.TrimSpace(currentHash) 68 | 69 | return currentHash, nil 70 | } 71 | 72 | func (p *GitProvider) sourcePath() string { 73 | return filepath.Join(p.appDir, ".git") 74 | } 75 | 76 | func execGitCommand(dir string, args ...string) (string, error) { 77 | cmd := exec.Command("git", args...) 78 | cmd.Dir = dir 79 | 80 | var stdout, stderr bytes.Buffer 81 | cmd.Stdout = &stdout 82 | cmd.Stderr = &stderr 83 | 84 | err := cmd.Run() 85 | if err != nil { 86 | return "", fmt.Errorf("git error: %s. %w", stderr.String(), err) 87 | } 88 | 89 | return stdout.String(), nil 90 | } 91 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1737729971, 7 | "owner": "cachix", 8 | "repo": "devenv", 9 | "rev": "68a6d54dbeb5622b8435d7f1acf9ce239a075635", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "dir": "src/modules", 14 | "owner": "cachix", 15 | "repo": "devenv", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-compat": { 20 | "flake": false, 21 | "locked": { 22 | "lastModified": 1733328505, 23 | "owner": "edolstra", 24 | "repo": "flake-compat", 25 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "edolstra", 30 | "repo": "flake-compat", 31 | "type": "github" 32 | } 33 | }, 34 | "gitignore": { 35 | "inputs": { 36 | "nixpkgs": [ 37 | "pre-commit-hooks", 38 | "nixpkgs" 39 | ] 40 | }, 41 | "locked": { 42 | "lastModified": 1709087332, 43 | "owner": "hercules-ci", 44 | "repo": "gitignore.nix", 45 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "hercules-ci", 50 | "repo": "gitignore.nix", 51 | "type": "github" 52 | } 53 | }, 54 | "nixpkgs": { 55 | "locked": { 56 | "lastModified": 1733477122, 57 | "owner": "cachix", 58 | "repo": "devenv-nixpkgs", 59 | "rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857", 60 | "type": "github" 61 | }, 62 | "original": { 63 | "owner": "cachix", 64 | "ref": "rolling", 65 | "repo": "devenv-nixpkgs", 66 | "type": "github" 67 | } 68 | }, 69 | "pre-commit-hooks": { 70 | "inputs": { 71 | "flake-compat": "flake-compat", 72 | "gitignore": "gitignore", 73 | "nixpkgs": [ 74 | "nixpkgs" 75 | ] 76 | }, 77 | "locked": { 78 | "lastModified": 1737465171, 79 | "owner": "cachix", 80 | "repo": "pre-commit-hooks.nix", 81 | "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17", 82 | "type": "github" 83 | }, 84 | "original": { 85 | "owner": "cachix", 86 | "repo": "pre-commit-hooks.nix", 87 | "type": "github" 88 | } 89 | }, 90 | "root": { 91 | "inputs": { 92 | "devenv": "devenv", 93 | "nixpkgs": "nixpkgs", 94 | "pre-commit-hooks": "pre-commit-hooks" 95 | } 96 | } 97 | }, 98 | "root": "root", 99 | "version": 7 100 | } 101 | -------------------------------------------------------------------------------- /cmd/deploy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/urfave/cli/v2" 11 | 12 | "github.com/serversfordev/deploy/internal/config" 13 | "github.com/serversfordev/deploy/internal/deployer" 14 | "github.com/serversfordev/deploy/internal/logger" 15 | "github.com/serversfordev/deploy/internal/provider" 16 | "github.com/serversfordev/deploy/internal/utils" 17 | ) 18 | 19 | var ( 20 | version = "dev" 21 | commit = "none" 22 | date = "unknown" 23 | ) 24 | 25 | var app = &cli.App{ 26 | Name: "deploy", 27 | Usage: "a simple application deployment tool", 28 | Version: version, 29 | Commands: []*cli.Command{ 30 | { 31 | Name: "init", 32 | Usage: "initialize new application deployment structure", 33 | Action: initCommand, 34 | Flags: []cli.Flag{ 35 | &cli.StringFlag{ 36 | Name: "name", 37 | Aliases: []string{"n"}, 38 | Usage: "application name", 39 | Required: true, 40 | }, 41 | }, 42 | }, 43 | { 44 | Name: "start", 45 | Usage: "start deployment process", 46 | Action: startCommand, 47 | Flags: []cli.Flag{ 48 | &cli.StringFlag{ 49 | Name: "file", 50 | Aliases: []string{"f"}, 51 | Usage: "path to app.yaml configuration file", 52 | }, 53 | &cli.BoolFlag{ 54 | Name: "force", 55 | Usage: "force deployment even if no changes detected", 56 | Value: false, 57 | }, 58 | }, 59 | }, 60 | { 61 | Name: "version", 62 | Usage: "print version information", 63 | Action: versionCommand, 64 | }, 65 | }, 66 | } 67 | 68 | func main() { 69 | if err := app.Run(os.Args); err != nil { 70 | log.Fatal(err) 71 | } 72 | } 73 | 74 | func initCommand(c *cli.Context) error { 75 | appName := c.String("name") 76 | appName = utils.NormalizeAppName(appName) 77 | 78 | appDir, err := utils.InitializeAppStructure(appName) 79 | if err != nil { 80 | return fmt.Errorf("failed to initialize app structure: %w", err) 81 | } 82 | 83 | fmt.Printf("successfully initialized deployment structure for %s under %s\n", appName, appDir) 84 | 85 | return nil 86 | } 87 | 88 | func startCommand(c *cli.Context) error { 89 | var configPath string 90 | if c.String("file") != "" { 91 | configPath = c.String("file") 92 | } else { 93 | configPath = "config.toml" 94 | } 95 | 96 | configPath, err := filepath.Abs(configPath) 97 | if err != nil { 98 | return fmt.Errorf("failed to resolve config path: %w", err) 99 | } 100 | 101 | cfg, err := config.Load(configPath) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | appDir, err := filepath.Abs(filepath.Dir(configPath)) 107 | if err != nil { 108 | return fmt.Errorf("failed to get absolute path: %w", err) 109 | } 110 | 111 | logger, err := logger.New(appDir) 112 | if err != nil { 113 | return fmt.Errorf("failed to create logger: %w", err) 114 | } 115 | 116 | p, err := provider.New(cfg, appDir) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | ctx := deployer.Context{ 122 | Logger: logger, 123 | Config: cfg, 124 | Provider: p, 125 | AppDir: appDir, 126 | Force: c.Bool("force"), 127 | } 128 | 129 | deployer := deployer.New() 130 | err = deployer.Execute(&ctx) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | return nil 136 | } 137 | 138 | func versionCommand(c *cli.Context) error { 139 | fmt.Printf("Version: %s\n", version) 140 | fmt.Printf("Commit: %s\n", commit) 141 | fmt.Printf("Built: %s\n", date) 142 | fmt.Printf("Go version: %s\n", runtime.Version()) 143 | fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) 144 | 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 8 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 9 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 10 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 14 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 15 | github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= 16 | github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= 17 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= 18 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= 19 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 20 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 24 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 25 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 26 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 27 | github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 28 | github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 29 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 30 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 31 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 32 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 33 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 34 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 35 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 36 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 37 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= 38 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 39 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 40 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | -------------------------------------------------------------------------------- /internal/release/release.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "time" 9 | ) 10 | 11 | // ErrNoCurrentRelease indicates that there is no current release symlink 12 | var ErrNoCurrentRelease = fmt.Errorf("no current release exists") 13 | 14 | func UpdateCurrent(appDir string, releaseDir string) error { 15 | currentSymlink := filepath.Join(appDir, "current") 16 | previousSymlink := filepath.Join(appDir, "previous") 17 | 18 | // Set previous symlink if current exists 19 | if currentSymlinkTarget, err := os.Readlink(currentSymlink); err == nil { 20 | err = os.Remove(previousSymlink) 21 | if err != nil { 22 | return fmt.Errorf("failed to remove previous symlink: %w", err) 23 | } 24 | 25 | err = os.Symlink(currentSymlinkTarget, previousSymlink) 26 | if err != nil { 27 | return fmt.Errorf("failed to create previous symlink: %w", err) 28 | } 29 | } 30 | 31 | tmpLink := currentSymlink + ".tmp" 32 | if err := os.Symlink(releaseDir, tmpLink); err != nil { 33 | return fmt.Errorf("failed to create temporary symlink: %w", err) 34 | } 35 | if err := os.Rename(tmpLink, currentSymlink); err != nil { 36 | os.Remove(tmpLink) 37 | return fmt.Errorf("failed to update current symlink: %w", err) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func Rollback(appDir string) error { 44 | previousSymlink := filepath.Join(appDir, "previous") 45 | 46 | previousTarget, err := os.Readlink(previousSymlink) 47 | if err != nil { 48 | return fmt.Errorf("failed to read previous symlink: %w", err) 49 | } 50 | 51 | if err := UpdateCurrent(appDir, previousTarget); err != nil { 52 | return fmt.Errorf("failed to rollback to previous release: %w", err) 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func NewRelease(appDir string, revisionID string) (string, error) { 59 | timestamp := time.Now().Format("20060102_150405") 60 | releaseDir := filepath.Join(appDir, "releases", timestamp) 61 | if err := os.MkdirAll(releaseDir, 0755); err != nil { 62 | return "", fmt.Errorf("failed to create release directory: %w", err) 63 | } 64 | 65 | revisionFile := filepath.Join(releaseDir, "REVISION") 66 | if err := os.WriteFile(revisionFile, []byte(revisionID), 0644); err != nil { 67 | return "", fmt.Errorf("failed to write revision file: %w", err) 68 | } 69 | 70 | return releaseDir, nil 71 | } 72 | 73 | func CurrentRevision(appDir string) (string, error) { 74 | currentSymlink := filepath.Join(appDir, "current") 75 | if _, err := os.Readlink(currentSymlink); err != nil { 76 | if os.IsNotExist(err) { 77 | return "", ErrNoCurrentRelease 78 | } 79 | return "", fmt.Errorf("failed to read current symlink: %w", err) 80 | } 81 | 82 | revisionFile := filepath.Join(appDir, "current", "REVISION") 83 | revision, err := os.ReadFile(revisionFile) 84 | if err != nil { 85 | return "", fmt.Errorf("failed to read revision file: %w", err) 86 | } 87 | 88 | return string(revision), nil 89 | } 90 | 91 | func CleanupRelease(releaseDir string) error { 92 | return os.RemoveAll(releaseDir) 93 | } 94 | 95 | func CleanupOldReleases(appDir string, keep int) error { 96 | releasesDir := filepath.Join(appDir, "releases") 97 | releases, err := os.ReadDir(releasesDir) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | type releaseDirInfo struct { 103 | name string 104 | created time.Time 105 | } 106 | 107 | var releaseDirInfos []releaseDirInfo 108 | for _, releaseDir := range releases { 109 | if releaseDir.IsDir() { 110 | path := filepath.Join(releasesDir, releaseDir.Name()) 111 | 112 | info, err := os.Stat(path) 113 | if err != nil { 114 | return fmt.Errorf("failed to get file info for %s: %w", path, err) 115 | } 116 | 117 | releaseDirInfos = append(releaseDirInfos, releaseDirInfo{ 118 | name: releaseDir.Name(), 119 | created: info.ModTime(), 120 | }) 121 | } 122 | } 123 | 124 | sort.Slice(releaseDirInfos, func(i, j int) bool { 125 | return releaseDirInfos[i].created.Before(releaseDirInfos[j].created) 126 | }) 127 | 128 | if len(releaseDirInfos) <= keep { 129 | return nil 130 | } 131 | 132 | for _, releaseDirInfo := range releaseDirInfos[:len(releaseDirInfos)-keep] { 133 | if err := os.RemoveAll(filepath.Join(releasesDir, releaseDirInfo.name)); err != nil { 134 | return fmt.Errorf("failed to remove release %s: %w", releaseDirInfo.name, err) 135 | } 136 | } 137 | 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /cmd/deploy/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "testing" 9 | 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | 13 | "github.com/serversfordev/deploy/internal/config" 14 | ) 15 | 16 | var ( 17 | workingDir string 18 | ) 19 | 20 | func TestDeploy(t *testing.T) { 21 | RegisterFailHandler(Fail) 22 | RunSpecs(t, "Deploy test suite") 23 | } 24 | 25 | var _ = BeforeSuite(func() { 26 | var err error 27 | 28 | workingDir, err = os.Getwd() 29 | Expect(err).NotTo(HaveOccurred()) 30 | 31 | workingDir = filepath.Join(workingDir, "testdata") 32 | Expect(err).NotTo(HaveOccurred()) 33 | 34 | err = os.RemoveAll(workingDir) 35 | Expect(err).NotTo(HaveOccurred()) 36 | }) 37 | 38 | var _ = Describe("Deploy", func() { 39 | Context("init command", func() { 40 | It("should create a new application deployment structure", func() { 41 | // create a new test environment 42 | env, err := NewTestEnv(workingDir, "init-test-1") 43 | Expect(err).NotTo(HaveOccurred()) 44 | Expect(env.Dir).To(BeADirectory()) 45 | 46 | // change working directory to the test environment 47 | err = os.Chdir(env.Dir) 48 | Expect(err).NotTo(HaveOccurred()) 49 | 50 | // run the init command 51 | err = app.Run([]string{"deploy", "init", "-n", "app"}) 52 | Expect(err).NotTo(HaveOccurred()) 53 | 54 | // check that the application structure was created 55 | Expect(filepath.Join(env.Dir, "app")).To(BeADirectory()) 56 | Expect(filepath.Join(env.Dir, "app", "releases")).To(BeADirectory()) 57 | Expect(filepath.Join(env.Dir, "app", "shared")).To(BeADirectory()) 58 | Expect(filepath.Join(env.Dir, "app", "logs")).To(BeADirectory()) 59 | Expect(filepath.Join(env.Dir, "app", "config.toml")).To(BeAnExistingFile()) 60 | 61 | // check that the config file was created with the correct content 62 | generatedConfig, err := config.Load(filepath.Join(env.Dir, "app", "config.toml")) 63 | Expect(err).NotTo(HaveOccurred()) 64 | 65 | defaultConfig := config.Default() 66 | Expect(generatedConfig).To(Equal(defaultConfig)) 67 | }) 68 | }) 69 | 70 | Context("deploy command", func() { 71 | It("should successfully deploy", func() { 72 | // create a new test environment 73 | env, err := NewTestEnv(workingDir, "deploy-test-1") 74 | Expect(err).NotTo(HaveOccurred()) 75 | Expect(env.Dir).To(BeADirectory()) 76 | 77 | // change working directory to the test environment 78 | err = os.Chdir(env.Dir) 79 | Expect(err).NotTo(HaveOccurred()) 80 | 81 | // init the app 82 | _, err = env.InitApp() 83 | Expect(err).NotTo(HaveOccurred()) 84 | 85 | // commit a file 86 | err = env.CommitFile("test1.txt") 87 | Expect(err).NotTo(HaveOccurred()) 88 | 89 | // start the deployment 90 | err = app.Run([]string{"deploy", "start", "-f", filepath.Join(env.Dir, "app", "config.toml")}) 91 | Expect(err).NotTo(HaveOccurred()) 92 | }) 93 | 94 | // force 95 | // release lock on error 96 | // release lock on success 97 | // rollback 98 | // hooks 99 | }) 100 | }) 101 | 102 | type testEnv struct { 103 | Dir string 104 | } 105 | 106 | func NewTestEnv(baseDir string, name string) (*testEnv, error) { 107 | // create a test directory 108 | dir := filepath.Join(baseDir, name) 109 | err := os.MkdirAll(dir, 0755) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | // set git author name and email if it is running on CI 115 | if os.Getenv("ENVIRONMENT") == "CI" { 116 | fmt.Println("CI environment detected, setting git author name and email") 117 | 118 | err = runGitCommand(dir, "config", "--global", "user.name", "test") 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | err = runGitCommand(dir, "config", "--global", "user.email", "test@example.com") 124 | if err != nil { 125 | return nil, err 126 | } 127 | } 128 | 129 | // create a git repository 130 | gitDir := filepath.Join(dir, "repo") 131 | err = os.MkdirAll(gitDir, 0755) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | err = runGitCommand(gitDir, "init") 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | err = runGitCommand(gitDir, "branch", "-m", "main") 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | return &testEnv{ 147 | Dir: dir, 148 | }, nil 149 | } 150 | 151 | func (t testEnv) InitApp() (string, error) { 152 | err := app.Run([]string{"deploy", "init", "-n", "app"}) 153 | if err != nil { 154 | return "", err 155 | } 156 | 157 | return filepath.Join(t.Dir, "app"), nil 158 | } 159 | 160 | func (t testEnv) CommitFile(filename string) error { 161 | repoDir := filepath.Join(t.Dir, "repo") 162 | filePath := filepath.Join(repoDir, filename) 163 | err := os.WriteFile(filePath, []byte("test content"), 0644) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | err = runGitCommand(repoDir, "add", filename) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | err = runGitCommand(repoDir, "commit", "-m", "add "+filename) 174 | if err != nil { 175 | return err 176 | } 177 | return nil 178 | } 179 | 180 | func (t testEnv) CreateHooks() error { 181 | hookLogFile := filepath.Join(t.Dir, "repo", ".deploy", "hooks.log") 182 | 183 | hookDir := filepath.Join(t.Dir, "repo", ".deploy", "hooks") 184 | err := os.MkdirAll(hookDir, 0755) 185 | if err != nil { 186 | return err 187 | } 188 | 189 | err = os.WriteFile(filepath.Join(hookDir, "build"), []byte(fmt.Sprintf("#!/bin/sh\necho 'build' >> %s\n", hookLogFile)), 0755) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | err = os.WriteFile(filepath.Join(hookDir, "deploy"), []byte(fmt.Sprintf("#!/bin/sh\necho 'deploy' >> %s\n", hookLogFile)), 0755) 195 | if err != nil { 196 | return err 197 | } 198 | 199 | err = os.WriteFile(filepath.Join(hookDir, "post_deploy"), []byte(fmt.Sprintf("#!/bin/sh\necho 'post_deploy' >> %s\n", hookLogFile)), 0755) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | err = os.WriteFile(filepath.Join(hookDir, "verify"), []byte(fmt.Sprintf("#!/bin/sh\necho 'verify' >> %s\n", hookLogFile)), 0755) 205 | if err != nil { 206 | return err 207 | } 208 | 209 | return nil 210 | } 211 | 212 | func runGitCommand(dir string, args ...string) error { 213 | cmd := exec.Command("git", args...) 214 | cmd.Dir = dir 215 | 216 | output, err := cmd.CombinedOutput() 217 | if err != nil { 218 | return fmt.Errorf("git command failed: %w\n%s", err, string(output)) 219 | } 220 | 221 | return nil 222 | } 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # deploy 2 | 3 | A lightweight, single-binary deployment tool written in Go. 4 | 5 | Key features: 6 | - Single statically linked binary with zero dependencies 7 | - Simple configuration with minimal boilerplate 8 | - Pull-based deployment strategy 9 | - Easy integration with cron jobs, webhooks, and git hooks 10 | - Atomic deployments with rollback capability 11 | 12 | ### Why? 13 | 14 | `deploy` was born from a desire (and from my personal frustrations) to have a simple deployment tool that: 15 | - works without external dependencies 16 | - is easy to integrate with existing workflows 17 | - is easy to configure 18 | - doesn't require complex server setup 19 | - perfect for personal/small projects 20 | 21 | ## Table of Contents 22 | 23 | - [Quick start](#quick-start) 24 | - [Configuration](#configuration) 25 | - [Hooks](#hooks) 26 | - [Deployment lifecycle](#deployment-lifecycle) 27 | 28 | ## Quick start 29 | 30 | ### Installation 31 | 32 | ```bash 33 | # Download the latest linux-amd64 binary 34 | curl -L https://github.com/serversfordev/deploy/releases/latest/download/deploy-linux-amd64 -o deploy 35 | 36 | # Make it executable 37 | chmod +x deploy 38 | 39 | # Move it to a directory in your PATH 40 | sudo mv deploy /usr/local/bin/deploy 41 | 42 | # Verify installation 43 | deploy --help 44 | ``` 45 | 46 | For other architectures, please see the [releases page](https://github.com/serversfordev/deploy/releases). 47 | 48 | ### Initializing deployment directory structure 49 | 50 | Run the `deploy init` command as the user that your application runs as. 51 | 52 | ```bash 53 | # Run as the user that your application runs as 54 | sudo -u www-data bash 55 | cd /var/www 56 | 57 | # Initialize the deployment directory structure 58 | deploy init --name app-name 59 | ``` 60 | 61 | The command will create the following directory structure: 62 | 63 | ``` 64 | /var/www/app-name/ 65 | ├── config.toml 66 | ├── current -> ./releases/20240209123456 67 | ├── releases/ 68 | │ ├── 20240209123456/ 69 | │ ├── 20240209123400/ 70 | │ └── 20240209123000/ 71 | ├── shared/ 72 | │ ├── .env 73 | │ └── storage/ 74 | └── logs/ 75 | ``` 76 | 77 | ### Configuration 78 | 79 | ```toml 80 | [source] 81 | provider = "git" 82 | [source.git] 83 | repo = "https://github.com/yourname/app-name.git" # Set the git repository 84 | branch = "main" # And the branch 85 | 86 | [deploy] 87 | keep_releases = 3 88 | [deploy.jitter] 89 | min = 5 90 | max = 10 91 | [deploy.shared] 92 | dirs = [] 93 | files = [] 94 | ``` 95 | 96 | ### Deploying 97 | 98 | Enter the deployment directory and run the `deploy start` command. 99 | 100 | ```bash 101 | # Enter the deployment directory 102 | cd /var/www/app-name 103 | 104 | # Start the deployment 105 | deploy start 106 | ``` 107 | 108 | You can trigger the start command from a cron job, a [webhook](https://github.com/adnanh/webhook), or a git hook. 109 | 110 | ## Configuration 111 | 112 | The configuration file (`config.toml`) defines how your application should be deployed. Here's a detailed explanation of each option: 113 | 114 | ### Source configuration 115 | 116 | ```toml 117 | [source] 118 | provider = "git" 119 | [source.git] 120 | repo = "https://github.com/yourname/app-name.git" 121 | branch = "main" 122 | ``` 123 | 124 | - `provider`: The source provider for your application (currently only "git" is supported) 125 | - `repo`: The Git repository URL of your application 126 | - `branch`: The branch to deploy from (defaults to "main") 127 | 128 | ### Deployment settings 129 | 130 | ```toml 131 | [deploy] 132 | keep_releases = 3 133 | [deploy.jitter] 134 | min = 5 135 | max = 10 136 | [deploy.shared] 137 | dirs = [] 138 | files = [] 139 | ``` 140 | 141 | #### General settings 142 | 143 | keep_releases: Number of releases to keep in the releases directory (defaults to 3) 144 | 145 | #### Jitter settings 146 | 147 | The jitter settings add a random delay before deployment to prevent multiple servers from deploying simultaneously: 148 | 149 | - `min`: Minimum delay in seconds before deployment starts 150 | - `max`: Maximum delay in seconds before deployment starts 151 | 152 | #### Shared resources 153 | 154 | The shared resources section defines files and directories that should be copied to the new release: 155 | 156 | Configure files and directories that should be shared between releases: 157 | 158 | - `dirs`: List of directories to be shared (e.g., ` ["storage", "uploads"]`) 159 | - `files`: List of files to be shared (e.g., `[".env"]`) 160 | 161 | ## Hooks 162 | 163 | Hooks allow you to customize the deployment process. Place your hook scripts in the .deploy/hooks directory in your application's repository. All hooks must be executable. 164 | 165 | ``` 166 | your-app/ 167 | └── .deploy/ 168 | └── hooks/ 169 | ├── clone 170 | ├── build 171 | ├── deploy 172 | ├── post_deploy 173 | └── verify 174 | ``` 175 | 176 | ### Available hooks 177 | 178 | - `clone`: Runs after the code is cloned into a new release directory, but before the shared resources are linked 179 | - `build`: Main build process (compile assets, install dependencies) 180 | - `deploy`: Runs during the deployment phase (before the current symlink is updated) 181 | - `post_deploy`: Runs after deployment is complete 182 | - `verify`: Runs verification checks after deployment 183 | 184 | ### Hook example 185 | Here's an example `build` hook for a Laravel application: 186 | 187 | ``` 188 | #!/bin/bash 189 | 190 | set -e 191 | 192 | echo "Building" 193 | 194 | mkdir -p storage/{app/public,framework/{cache,sessions,testing,views},logs} 195 | 196 | composer install --no-dev 197 | 198 | php artisan optimize:clear 199 | php artisan optimize 200 | 201 | php artisan storage:link 202 | 203 | php artisan migrate --force 204 | 205 | npm install 206 | npm run build 207 | ``` 208 | 209 | The hooks should be placed in your application's `.deploy/hooks` directory and must be executable (`chmod +x .deploy/hooks/build`). 210 | 211 | ## Deployment lifecycle 212 | 213 | The deployment process follows a specific lifecycle with multiple stages, described as a state machine under the `internal/deployer/deployer.go` file. 214 | 215 | ### 1. Initialize 216 | 217 | - Acquires deployment lock 218 | - Applies jitter delay if configured 219 | - Initializes the source provider 220 | 221 | 222 | ### 2. Detect Changes 223 | 224 | - Compares current release with remote source 225 | - Proceeds if changes detected or force flag is set 226 | - Skips deployment if no changes and no force flag 227 | 228 | 229 | ### 3. Clone 230 | 231 | - Creates new release directory 232 | - Clones the repository 233 | - Executes the `clone` hook 234 | - Links shared files and directories 235 | 236 | 237 | ### 4. Build 238 | 239 | - Executes the `build` hook 240 | - Typically used for compiling assets, installing dependencies 241 | - Must exit with 0 for deployment to continue 242 | 243 | 244 | ### 5. Deploy 245 | 246 | - Executes the `deploy` hook 247 | - Updates the current symlink to point to the new release 248 | - Prepares rollback in case of subsequent failures 249 | 250 | 251 | ### 6. Post-Deploy 252 | 253 | - Executes the `post_deploy` hook 254 | - Used for tasks that should run after the deployment is live 255 | - Example: cache warming, service notifications 256 | 257 | 258 | ### 7. Verify 259 | 260 | - Executes the `verify` hook 261 | - Validates the deployment 262 | - Non-zero exit triggers automatic rollback 263 | 264 | 265 | ### 8. Error 266 | 267 | - Rollback is executed on error in any state 268 | - Reverts to the previous release 269 | 270 | 271 | ### 9. Finalize 272 | 273 | - Cleans up old releases 274 | - Releases deployment lock 275 | -------------------------------------------------------------------------------- /internal/deployer/deployer.go: -------------------------------------------------------------------------------- 1 | package deployer 2 | 3 | import ( 4 | "fmt" 5 | "math/rand/v2" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/serversfordev/deploy/internal/config" 11 | "github.com/serversfordev/deploy/internal/hook" 12 | "github.com/serversfordev/deploy/internal/lock" 13 | "github.com/serversfordev/deploy/internal/logger" 14 | "github.com/serversfordev/deploy/internal/provider" 15 | "github.com/serversfordev/deploy/internal/release" 16 | ) 17 | 18 | type State string 19 | 20 | const ( 21 | StateInit State = "init" 22 | StateDetectChanges State = "detect_changes" 23 | StateClone State = "clone" 24 | StateBuild State = "build" 25 | StateDeploy State = "deploy" 26 | StatePostDeploy State = "post_deploy" 27 | StateVerify State = "verify" 28 | StateError State = "error" 29 | StateFinalize State = "finalize" 30 | StateEnd State = "end" 31 | ) 32 | 33 | var stateTransitions = map[State][]State{ 34 | StateInit: {StateDetectChanges, StateError}, 35 | StateDetectChanges: {StateClone, StateFinalize, StateError}, 36 | StateClone: {StateBuild, StateError}, 37 | StateBuild: {StateDeploy, StateError}, 38 | StateDeploy: {StatePostDeploy, StateError}, 39 | StatePostDeploy: {StateVerify, StateError}, 40 | StateVerify: {StateFinalize, StateError}, 41 | StateError: {StateFinalize}, 42 | StateFinalize: {StateEnd}, 43 | } 44 | 45 | type Context struct { 46 | Logger *logger.Logger 47 | Config *config.Config 48 | Provider provider.Provider 49 | AppDir string 50 | Force bool 51 | NewReleaseDir string 52 | 53 | rollbackFuncs []func() error 54 | } 55 | 56 | func (ctx *Context) AddRollbackFunc(fn func() error) { 57 | ctx.rollbackFuncs = append(ctx.rollbackFuncs, fn) 58 | } 59 | 60 | func (ctx *Context) ExecuteRollback() { 61 | // Execute rollback functions in reverse order (LIFO) 62 | for i := len(ctx.rollbackFuncs) - 1; i >= 0; i-- { 63 | if err := ctx.rollbackFuncs[i](); err != nil { 64 | ctx.Logger.Printf("rollback function %d failed: %s", i, err) 65 | } 66 | } 67 | 68 | // Clear rollback functions after execution 69 | ctx.rollbackFuncs = nil 70 | } 71 | 72 | type StateHandler func(ctx *Context) (State, error) 73 | 74 | var stateHandlers = map[State]StateHandler{ 75 | StateInit: func(ctx *Context) (State, error) { 76 | ctx.Logger.Printf("initializing") 77 | 78 | ctx.Logger.Printf("acquiring lock") 79 | if err := lock.Acquire(ctx.AppDir); err != nil { 80 | ctx.Logger.Printf("failed to acquire lock: %s", err) 81 | return StateError, nil 82 | } 83 | 84 | if ctx.Config.Deploy.Jitter.Min != 0 && ctx.Config.Deploy.Jitter.Max != 0 { 85 | min := float64(ctx.Config.Deploy.Jitter.Min) 86 | max := float64(ctx.Config.Deploy.Jitter.Max) 87 | 88 | jitterSeconds := min + rand.Float64()*(max-min) 89 | 90 | ctx.Logger.Printf("jittering for %d seconds", int(jitterSeconds)) 91 | time.Sleep(time.Duration(jitterSeconds * float64(time.Second))) 92 | } 93 | 94 | if err := ctx.Provider.Init(); err != nil { 95 | ctx.Logger.Printf("failed to initialize provider: %s", err) 96 | return StateError, nil 97 | } 98 | 99 | return StateDetectChanges, nil 100 | }, 101 | 102 | StateDetectChanges: func(ctx *Context) (State, error) { 103 | ctx.Logger.Printf("detecting changes") 104 | 105 | currentRevision, err := release.CurrentRevision(ctx.AppDir) 106 | if err != nil { 107 | if err == release.ErrNoCurrentRelease { 108 | return StateClone, nil 109 | } 110 | 111 | ctx.Logger.Printf("failed to get current revision: %s", err) 112 | return StateError, nil 113 | } 114 | 115 | providerRevision, err := ctx.Provider.GetRevision() 116 | if err != nil { 117 | ctx.Logger.Printf("failed to get provider revision: %s", err) 118 | return StateError, nil 119 | } 120 | 121 | if currentRevision != providerRevision { 122 | ctx.Logger.Printf("changes detected") 123 | return StateClone, nil 124 | } 125 | 126 | if ctx.Force { 127 | ctx.Logger.Printf("forcing deployment") 128 | return StateClone, nil 129 | } 130 | 131 | ctx.Logger.Printf("no changes detected, skipping deployment") 132 | 133 | return StateFinalize, nil 134 | }, 135 | 136 | StateClone: func(ctx *Context) (State, error) { 137 | ctx.Logger.Printf("cloning") 138 | 139 | providerRevision, err := ctx.Provider.GetRevision() 140 | if err != nil { 141 | ctx.Logger.Printf("failed to get provider revision: %s", err) 142 | return StateError, nil 143 | } 144 | 145 | ctx.NewReleaseDir, err = release.NewRelease(ctx.AppDir, providerRevision) 146 | if err != nil { 147 | ctx.Logger.Printf("failed to create new release: %s", err) 148 | return StateError, nil 149 | } 150 | 151 | // cleaning up in case of failure 152 | ctx.AddRollbackFunc(func() error { 153 | ctx.Logger.Printf("cleaning up new release") 154 | return release.CleanupRelease(ctx.NewReleaseDir) 155 | }) 156 | ctx.Logger.Printf("new release directory created: %s", providerRevision) 157 | 158 | if err := ctx.Provider.Clone(ctx.NewReleaseDir); err != nil { 159 | ctx.Logger.Printf("failed to clone provider: %s", err) 160 | return StateError, nil 161 | } 162 | 163 | ctx.Logger.Printf("executing clone hook") 164 | if err := hook.ExecuteHook(ctx.NewReleaseDir, hook.HookClone); err != nil { 165 | ctx.Logger.Printf("failed to execute clone hook: %s", err) 166 | return StateError, nil 167 | } 168 | 169 | ctx.Logger.Printf("linking shared files and dirs") 170 | for _, dir := range ctx.Config.Deploy.Shared.Dirs { 171 | sharedDirPath := filepath.Join(ctx.AppDir, "shared", dir) 172 | releaseDirPath := filepath.Join(ctx.NewReleaseDir, dir) 173 | 174 | _ = os.RemoveAll(releaseDirPath) 175 | 176 | if err := os.Symlink(sharedDirPath, releaseDirPath); err != nil { 177 | ctx.Logger.Printf("failed to symlink shared dir: %s", err) 178 | return StateError, nil 179 | } 180 | } 181 | 182 | for _, file := range ctx.Config.Deploy.Shared.Files { 183 | sharedFilePath := filepath.Join(ctx.AppDir, "shared", file) 184 | releaseFilePath := filepath.Join(ctx.NewReleaseDir, file) 185 | 186 | _ = os.RemoveAll(releaseFilePath) 187 | 188 | if err := os.Symlink(sharedFilePath, releaseFilePath); err != nil { 189 | ctx.Logger.Printf("failed to symlink shared file: %s", err) 190 | return StateError, nil 191 | } 192 | } 193 | 194 | return StateBuild, nil 195 | }, 196 | 197 | StateBuild: func(ctx *Context) (State, error) { 198 | ctx.Logger.Printf("executing build hook") 199 | if err := hook.ExecuteHook(ctx.NewReleaseDir, hook.HookBuild); err != nil { 200 | ctx.Logger.Printf("failed to execute build hook: %s", err) 201 | return StateError, nil 202 | } 203 | 204 | return StateDeploy, nil 205 | }, 206 | 207 | StateDeploy: func(ctx *Context) (State, error) { 208 | ctx.Logger.Printf("executing deploy hook") 209 | if err := hook.ExecuteHook(ctx.NewReleaseDir, hook.HookDeploy); err != nil { 210 | ctx.Logger.Printf("failed to execute deploy hook: %s", err) 211 | return StateError, nil 212 | } 213 | 214 | ctx.Logger.Printf("updating current release") 215 | err := release.UpdateCurrent(ctx.AppDir, ctx.NewReleaseDir) 216 | if err != nil { 217 | ctx.Logger.Printf("failed to update current release: %s", err) 218 | return StateError, nil 219 | } 220 | 221 | // rollback in case of failure 222 | ctx.AddRollbackFunc(func() error { 223 | ctx.Logger.Printf("rolling back") 224 | return release.Rollback(ctx.AppDir) 225 | }) 226 | 227 | return StatePostDeploy, nil 228 | }, 229 | 230 | StatePostDeploy: func(ctx *Context) (State, error) { 231 | ctx.Logger.Printf("executing post deploy hook") 232 | if err := hook.ExecuteHook(ctx.NewReleaseDir, hook.HookPostDeploy); err != nil { 233 | ctx.Logger.Printf("failed to execute post deploy hook: %s", err) 234 | return StateError, nil 235 | } 236 | 237 | return StateVerify, nil 238 | }, 239 | 240 | StateVerify: func(ctx *Context) (State, error) { 241 | ctx.Logger.Printf("executing verify hook") 242 | if err := hook.ExecuteHook(ctx.NewReleaseDir, hook.HookVerify); err != nil { 243 | ctx.Logger.Printf("verification hook returned with a non-zero exit code") 244 | 245 | return StateError, nil 246 | } 247 | 248 | return StateFinalize, nil 249 | }, 250 | 251 | StateError: func(ctx *Context) (State, error) { 252 | ctx.Logger.Printf("rolling back") 253 | 254 | ctx.ExecuteRollback() 255 | 256 | return StateFinalize, nil 257 | }, 258 | 259 | StateFinalize: func(ctx *Context) (State, error) { 260 | ctx.Logger.Printf("finalizing") 261 | 262 | ctx.Logger.Printf("cleaning up old releases") 263 | if err := release.CleanupOldReleases(ctx.AppDir, ctx.Config.Deploy.KeepReleases); err != nil { 264 | ctx.Logger.Printf("failed to cleanup old releases: %s", err) 265 | } 266 | 267 | ctx.Logger.Printf("releasing lock") 268 | if err := lock.Release(ctx.AppDir); err != nil { 269 | ctx.Logger.Printf("failed to release lock: %s", err) 270 | } 271 | 272 | return StateEnd, nil 273 | }, 274 | } 275 | 276 | type Deployer struct { 277 | currentState State 278 | transitions map[State][]State 279 | handlers map[State]StateHandler 280 | } 281 | 282 | func New() *Deployer { 283 | d := &Deployer{ 284 | currentState: StateInit, 285 | transitions: stateTransitions, 286 | handlers: stateHandlers, 287 | } 288 | 289 | return d 290 | } 291 | 292 | func (d *Deployer) Execute(ctx *Context) error { 293 | for { 294 | if d.currentState == StateEnd { 295 | break 296 | } 297 | 298 | handler, exists := d.handlers[d.currentState] 299 | if !exists { 300 | return fmt.Errorf("no handler for state: %s", d.currentState) 301 | } 302 | 303 | nextState, err := handler(ctx) 304 | if err != nil { 305 | return err 306 | } 307 | 308 | if !d.isValidTransition(nextState) { 309 | return fmt.Errorf("invalid state transition: %s -> %s", d.currentState, nextState) 310 | } 311 | 312 | d.currentState = nextState 313 | } 314 | 315 | return nil 316 | } 317 | 318 | func (d *Deployer) isValidTransition(next State) bool { 319 | validStates, exists := d.transitions[d.currentState] 320 | if !exists { 321 | return false 322 | } 323 | 324 | for _, state := range validStates { 325 | if state == next { 326 | return true 327 | } 328 | } 329 | 330 | return false 331 | } 332 | --------------------------------------------------------------------------------