Pull-based GitOps-style Docker Compose deployer: polls a (private) Git repo, detects changed stacks and reconciles only the affected

refactor: refactor to a more convenient structure

+550 -359
+31
.github/workflows/release.yml
··· 1 + name: Release 2 + 3 + on: 4 + push: 5 + tags: 6 + - 'v*' 7 + 8 + permissions: 9 + contents: write 10 + 11 + jobs: 12 + release: 13 + runs-on: ubuntu-latest 14 + steps: 15 + - uses: actions/checkout@v4 16 + with: 17 + fetch-depth: 0 18 + 19 + - uses: actions/setup-go@v5 20 + with: 21 + go-version: '1.21' 22 + 23 + - name: Run GoReleaser 24 + uses: goreleaser/goreleaser-action@v5 25 + with: 26 + distribution: goreleaser 27 + version: latest 28 + args: release --clean 29 + env: 30 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 +
+7 -1
.gitignore
··· 31 31 # .idea/ 32 32 .vscode/ 33 33 34 + # Binaries 34 35 compose-sync 35 - config.yml 36 + dist/ 37 + 38 + # Config 39 + config.yml 40 + 41 + !cmd/compose-sync/
+41
.goreleaser.yml
··· 1 + version: 2 2 + 3 + project_name: compose-sync 4 + 5 + builds: 6 + - id: compose-sync 7 + main: ./cmd/compose-sync 8 + binary: compose-sync 9 + goos: 10 + - linux 11 + - darwin 12 + goarch: 13 + - amd64 14 + - arm64 15 + env: 16 + - CGO_ENABLED=0 17 + ldflags: 18 + - -s -w 19 + - -X github.com/aottr/compose-sync/internal/version.Version={{.Version}} 20 + - -X github.com/aottr/compose-sync/internal/version.Commit={{.Commit}} 21 + - -X github.com/aottr/compose-sync/internal/version.BuildDate={{.Date}} 22 + 23 + archives: 24 + - id: default 25 + files: 26 + - README.md 27 + - LICENSE 28 + - config.yml.example 29 + - systemd/* 30 + 31 + changelog: 32 + sort: asc 33 + filters: 34 + exclude: 35 + - "^docs:" 36 + - "^test:" 37 + 38 + release: 39 + github: 40 + owner: aottr 41 + name: compose-sync
+61
Makefile
··· 1 + .PHONY: build build-version major minor feature 2 + 3 + VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 4 + COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 5 + BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) 6 + 7 + LDFLAGS = -s -w \ 8 + -X github.com/aottr/compose-sync/internal/version.Version=$(VERSION) \ 9 + -X github.com/aottr/compose-sync/internal/version.Commit=$(COMMIT) \ 10 + -X github.com/aottr/compose-sync/internal/version.BuildDate=$(BUILD_DATE) 11 + 12 + # Get current version tag (removes 'v' prefix if present, defaults to 0.0.0) 13 + CURRENT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.0.0") 14 + CURRENT_MAJOR := $(shell echo "$(CURRENT_TAG)" | awk -F. '{print $$1}' | grep -E '^[0-9]+$$' || echo "0") 15 + CURRENT_MINOR := $(shell echo "$(CURRENT_TAG)" | awk -F. '{print $$2}' | grep -E '^[0-9]+$$' || echo "0") 16 + CURRENT_PATCH := $(shell echo "$(CURRENT_TAG)" | awk -F. '{print $$3}' | grep -E '^[0-9]+$$' || echo "0") 17 + 18 + build: 19 + go build -ldflags "$(LDFLAGS)" -o compose-sync ./cmd/compose-sync 20 + 21 + build-version: build 22 + @./compose-sync -version 23 + 24 + major: 25 + @CURRENT="$(CURRENT_TAG)"; \ 26 + if [ -z "$$CURRENT" ] || [ "$$CURRENT" = "0.0.0" ]; then \ 27 + NEW_VERSION="1.0.0"; \ 28 + else \ 29 + MAJOR=$$(echo "$$CURRENT" | awk -F. '{print $$1}'); \ 30 + NEW_VERSION="$$(($$MAJOR + 1)).0.0"; \ 31 + fi; \ 32 + echo "Bumping major version: $$CURRENT -> $$NEW_VERSION"; \ 33 + git -c tag.gpgSign=false tag -a "v$$NEW_VERSION" -m "Release v$$NEW_VERSION" || git tag "v$$NEW_VERSION"; \ 34 + echo "Created tag v$$NEW_VERSION. Push with: git push origin v$$NEW_VERSION" 35 + 36 + minor: 37 + @CURRENT="$(CURRENT_TAG)"; \ 38 + if [ -z "$$CURRENT" ] || [ "$$CURRENT" = "0.0.0" ]; then \ 39 + NEW_VERSION="0.1.0"; \ 40 + else \ 41 + MAJOR=$$(echo "$$CURRENT" | awk -F. '{print $$1}'); \ 42 + MINOR=$$(echo "$$CURRENT" | awk -F. '{print $$2}'); \ 43 + NEW_VERSION="$$MAJOR.$$(($$MINOR + 1)).0"; \ 44 + fi; \ 45 + echo "Bumping minor version: $$CURRENT -> $$NEW_VERSION"; \ 46 + git -c tag.gpgSign=false tag -a "v$$NEW_VERSION" -m "Release v$$NEW_VERSION" || git tag "v$$NEW_VERSION"; \ 47 + echo "Created tag v$$NEW_VERSION. Push with: git push origin v$$NEW_VERSION" 48 + 49 + patch: 50 + @CURRENT="$(CURRENT_TAG)"; \ 51 + if [ -z "$$CURRENT" ] || [ "$$CURRENT" = "0.0.0" ]; then \ 52 + NEW_VERSION="0.0.1"; \ 53 + else \ 54 + MAJOR=$$(echo "$$CURRENT" | awk -F. '{print $$1}'); \ 55 + MINOR=$$(echo "$$CURRENT" | awk -F. '{print $$2}'); \ 56 + PATCH=$$(echo "$$CURRENT" | awk -F. '{print $$3}'); \ 57 + NEW_VERSION="$$MAJOR.$$MINOR.$$(($$PATCH + 1))"; \ 58 + fi; \ 59 + echo "Bumping patch version: $$CURRENT -> $$NEW_VERSION"; \ 60 + git -c tag.gpgSign=false tag -a "v$$NEW_VERSION" -m "Release v$$NEW_VERSION" || git tag "v$$NEW_VERSION"; \ 61 + echo "Created tag v$$NEW_VERSION. Push with: git push origin v$$NEW_VERSION"
+7 -1
README.md
··· 50 50 51 51 ## Installation 52 52 53 + ### Using Go Install 54 + 53 55 ```bash 54 56 go install github.com/aottr/compose-sync@latest 55 57 ``` 56 58 57 - #### Alternatively build from source 59 + ### Using Pre-built Binaries 60 + 61 + Download the latest release from the [Releases page](https://github.com/aottr/compose-sync/releases). 62 + 63 + ### Build from Source 58 64 59 65 1. Clone this repository 60 66 2. Build the application:
+89
cmd/compose-sync/main.go
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "log" 7 + "os" 8 + "path/filepath" 9 + "slices" 10 + 11 + "github.com/aottr/compose-sync/internal/core" 12 + "github.com/aottr/compose-sync/internal/stacks" 13 + "github.com/aottr/compose-sync/internal/version" 14 + ) 15 + 16 + func main() { 17 + configPath := flag.String("config", "config.yml", "Path to configuration file") 18 + showVersion := flag.Bool("version", false, "Show version information") 19 + flag.Parse() 20 + 21 + if *showVersion { 22 + fmt.Printf("compose-sync version %s\n", version.Version) 23 + fmt.Printf("Commit: %s\n", version.Commit) 24 + fmt.Printf("Build date: %s\n", version.BuildDate) 25 + os.Exit(0) 26 + } 27 + 28 + cfg, err := core.LoadConfig(*configPath) 29 + if err != nil { 30 + log.Fatalf("Failed to load config: %v", err) 31 + } 32 + 33 + lockPath := filepath.Join(cfg.RepoPath, ".compose-sync.lock") 34 + releaseLock, err := core.AcquireLock(lockPath) 35 + if err != nil { 36 + log.Fatalf("Failed to acquire lock: %v", err) 37 + } 38 + defer func() { 39 + if err := releaseLock(); err != nil { 40 + log.Printf("Warning: failed to release lock: %v", err) 41 + } 42 + }() 43 + 44 + currentHost, err := core.DetectHost() 45 + if err != nil { 46 + log.Fatalf("Failed to detect host: %v", err) 47 + } 48 + fmt.Printf("Detected host: %s\n", currentHost) 49 + 50 + fmt.Printf("Pulling git repository (branch: %s)...\n", cfg.Branch) 51 + changedFiles, err := core.PullAndDetectChanges(cfg.RepoPath, cfg.Branch) 52 + if err != nil { 53 + log.Fatalf("Failed to pull or detect changes: %v", err) 54 + } 55 + 56 + if len(changedFiles) == 0 { 57 + fmt.Println("No changes detected.") 58 + return 59 + } 60 + 61 + changedStacks, err := stacks.DetectChangedStacks(changedFiles) 62 + if err != nil { 63 + log.Fatalf("Failed to detect changed stacks: %v", err) 64 + } 65 + 66 + assignedStacks, err := core.GetAssignedStacks(cfg.RepoPath, currentHost) 67 + if err != nil { 68 + log.Fatalf("Failed to get assigned stacks: %v", err) 69 + } 70 + fmt.Printf("Stacks assigned to this host: %v\n", assignedStacks) 71 + 72 + fmt.Printf("Changed stacks: %v\n", changedStacks) 73 + 74 + stacksToDeploy := []string{} 75 + for _, stack := range changedStacks { 76 + if slices.Contains(assignedStacks, stack) { 77 + stacksToDeploy = append(stacksToDeploy, stack) 78 + } 79 + } 80 + 81 + if len(stacksToDeploy) == 0 { 82 + fmt.Println("No changed stacks are assigned to this host.") 83 + return 84 + } 85 + 86 + fmt.Printf("Stacks to deploy: %v\n", stacksToDeploy) 87 + 88 + stacks.DeployStacks(cfg.RepoPath, stacksToDeploy, cfg.Concurrency) 89 + }
+44
internal/core/config.go
··· 1 + package core 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "gopkg.in/yaml.v3" 8 + ) 9 + 10 + type Config struct { 11 + RepoURL string `yaml:"repo_url"` 12 + RepoPath string `yaml:"repo_path"` 13 + Branch string `yaml:"branch"` 14 + Concurrency int `yaml:"concurrency"` 15 + } 16 + 17 + func LoadConfig(path string) (*Config, error) { 18 + data, err := os.ReadFile(path) 19 + if err != nil { 20 + return nil, fmt.Errorf("failed to read config file: %w", err) 21 + } 22 + 23 + var cfg Config 24 + if err := yaml.Unmarshal(data, &cfg); err != nil { 25 + return nil, fmt.Errorf("failed to parse config: %w", err) 26 + } 27 + 28 + if cfg.RepoPath == "" { 29 + return nil, fmt.Errorf("repo_path is required in config") 30 + } 31 + 32 + if cfg.Branch == "" { 33 + cfg.Branch = detectCurrentBranch(cfg.RepoPath) 34 + if cfg.Branch == "" { 35 + cfg.Branch = "main" 36 + } 37 + } 38 + 39 + if cfg.Concurrency <= 0 { 40 + cfg.Concurrency = 3 41 + } 42 + 43 + return &cfg, nil 44 + }
+96
internal/core/git.go
··· 1 + package core 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "strings" 8 + ) 9 + 10 + func PullAndDetectChanges(repoPath, branch string) ([]string, error) { 11 + if err := ensureGitRepository(repoPath); err != nil { 12 + return nil, err 13 + } 14 + 15 + prevHead, err := getGitHead(repoPath) 16 + if err != nil { 17 + return nil, fmt.Errorf("failed to get previous HEAD: %w", err) 18 + } 19 + 20 + if err := gitFetchPull(repoPath, branch); err != nil { 21 + return nil, fmt.Errorf("failed to pull: %w", err) 22 + } 23 + 24 + newHead, err := getGitHead(repoPath) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to get new HEAD: %w", err) 27 + } 28 + if prevHead == newHead { 29 + return []string{}, nil 30 + } 31 + 32 + output, err := gitCommand(repoPath, "diff", "--name-only", prevHead, newHead) 33 + if err != nil { 34 + return nil, fmt.Errorf("failed to get changed files: %w", err) 35 + } 36 + changedFiles := strings.Split(strings.TrimSpace(string(output)), "\n") 37 + return changedFiles, nil 38 + } 39 + 40 + /** 41 + * Git helper functions 42 + */ 43 + 44 + func gitCommand(repoPath string, args ...string) ([]byte, error) { 45 + cmd := exec.Command("git", args...) 46 + cmd.Dir = repoPath 47 + return cmd.Output() 48 + } 49 + 50 + func detectCurrentBranch(repoPath string) string { 51 + output, err := gitCommand(repoPath, "rev-parse", "--abbrev-ref", "HEAD") 52 + if err != nil { 53 + return "" 54 + } 55 + return strings.TrimSpace(string(output)) 56 + } 57 + 58 + func ensureGitRepository(repoPath string) error { 59 + if _, err := os.Stat(repoPath); os.IsNotExist(err) { 60 + return fmt.Errorf("repository path does not exist: %s", repoPath) 61 + } 62 + 63 + if _, err := os.Stat(fmt.Sprintf("%s/.git", repoPath)); os.IsNotExist(err) { 64 + return fmt.Errorf("path is not a git repository: %s", repoPath) 65 + } 66 + return nil 67 + } 68 + 69 + func gitFetchPull(repoPath, branch string) error { 70 + if _, err := gitCommand(repoPath, "fetch", "origin", branch); err != nil { 71 + return fmt.Errorf("git fetch failed: %w", err) 72 + } 73 + 74 + currentBranch, _ := gitCommand(repoPath, "rev-parse", "--abbrev-ref", "HEAD") 75 + if strings.TrimSpace(string(currentBranch)) != branch { 76 + if _, err := gitCommand(repoPath, "checkout", branch); err != nil { 77 + return fmt.Errorf("git checkout failed: %w", err) 78 + } 79 + } 80 + 81 + cmd := exec.Command("git", "pull", "origin", branch) 82 + cmd.Dir = repoPath 83 + output, err := cmd.CombinedOutput() 84 + if err != nil { 85 + return fmt.Errorf("git pull failed: %s, %w", string(output), err) 86 + } 87 + return nil 88 + } 89 + 90 + func getGitHead(repoPath string) (string, error) { 91 + output, err := gitCommand(repoPath, "rev-parse", "HEAD") 92 + if err != nil { 93 + return "", fmt.Errorf("failed to get HEAD: %w", err) 94 + } 95 + return strings.TrimSpace(string(output)), nil 96 + }
+50
internal/core/host.go
··· 1 + package core 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + 8 + "gopkg.in/yaml.v3" 9 + ) 10 + 11 + func DetectHost() (string, error) { 12 + hostname, err := os.Hostname() 13 + if err != nil { 14 + return "", fmt.Errorf("failed to get hostname: %w", err) 15 + } 16 + return hostname, nil 17 + } 18 + 19 + type inventory struct { 20 + Hosts map[string][]string `yaml:"hosts"` 21 + } 22 + 23 + func GetAssignedStacks(repoPath, hostname string) ([]string, error) { 24 + inventoryFile := filepath.Join(repoPath, "inventory.yml") 25 + 26 + data, err := os.ReadFile(inventoryFile) 27 + if err != nil { 28 + return []string{}, fmt.Errorf("failed to read inventory file %s: %w", inventoryFile, err) 29 + } 30 + 31 + var inv inventory 32 + if err := yaml.Unmarshal(data, &inv); err != nil { 33 + return []string{}, fmt.Errorf("failed to parse inventory file %s: %w", inventoryFile, err) 34 + } 35 + 36 + if inv.Hosts == nil { 37 + return []string{}, fmt.Errorf("inventory file %s has no 'hosts' key", inventoryFile) 38 + } 39 + 40 + stacks, exists := inv.Hosts[hostname] 41 + if !exists { 42 + return []string{}, nil 43 + } 44 + 45 + if stacks == nil { 46 + return []string{}, nil 47 + } 48 + 49 + return stacks, nil 50 + }
+30
internal/core/lock.go
··· 1 + package core 2 + 3 + import ( 4 + "errors" 5 + "os" 6 + "syscall" 7 + ) 8 + 9 + func AcquireLock(lockPath string) (func() error, error) { 10 + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) 11 + if err != nil { 12 + return nil, err 13 + } 14 + 15 + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { 16 + _ = f.Close() 17 + return nil, errors.New("another compose-sync is already running") 18 + } 19 + 20 + return func() error { 21 + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { 22 + f.Close() 23 + return err 24 + } 25 + if err := f.Close(); err != nil { 26 + return err 27 + } 28 + return os.Remove(lockPath) 29 + }, nil 30 + }
+55
internal/stacks/deploy.go
··· 1 + package stacks 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "os" 7 + "os/exec" 8 + "path/filepath" 9 + "sync" 10 + ) 11 + 12 + func DeployStacks(repoPath string, stacks []string, concurrency int) { 13 + semaphore := make(chan struct{}, concurrency) 14 + var wg sync.WaitGroup 15 + 16 + for _, stack := range stacks { 17 + wg.Add(1) 18 + go func(stack string) { 19 + defer wg.Done() 20 + 21 + semaphore <- struct{}{} 22 + defer func() { <-semaphore }() 23 + 24 + composePath := filepath.Join(repoPath, "stacks", stack, "compose.yml") 25 + if _, err := os.Stat(composePath); os.IsNotExist(err) { 26 + composePath = filepath.Join(repoPath, "stacks", stack, "compose.yaml") 27 + } 28 + 29 + fmt.Printf("Deploying stack: %s\n", stack) 30 + if err := deployStack(composePath); err != nil { 31 + log.Printf("Failed to deploy stack %s: %v", stack, err) 32 + return 33 + } 34 + fmt.Printf("Successfully deployed stack: %s\n", stack) 35 + }(stack) 36 + } 37 + wg.Wait() 38 + } 39 + 40 + func deployStack(composePath string) error { 41 + if _, err := os.Stat(composePath); os.IsNotExist(err) { 42 + return fmt.Errorf("compose file does not exist: %s", composePath) 43 + } 44 + 45 + composeDir := filepath.Dir(composePath) 46 + 47 + cmd := exec.Command("docker", "compose", "-f", composePath, "up", "-d") 48 + cmd.Dir = composeDir 49 + output, err := cmd.CombinedOutput() 50 + if err != nil { 51 + return fmt.Errorf("docker compose up failed: %s, %w", string(output), err) 52 + } 53 + 54 + return nil 55 + }
+32
internal/stacks/detect.go
··· 1 + package stacks 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + func DetectChangedStacks(changedFiles []string) ([]string, error) { 8 + stackSet := make(map[string]bool) 9 + 10 + for _, file := range changedFiles { 11 + if file == "" { 12 + continue 13 + } 14 + 15 + if strings.HasPrefix(file, "stacks/") { 16 + parts := strings.Split(file, "/") 17 + if len(parts) >= 2 { 18 + stackName := parts[1] 19 + if len(parts) >= 3 && (parts[2] == "compose.yml" || parts[2] == "compose.yaml") { 20 + stackSet[stackName] = true 21 + } 22 + } 23 + } 24 + } 25 + 26 + stacks := make([]string, 0, len(stackSet)) 27 + for stack := range stackSet { 28 + stacks = append(stacks, stack) 29 + } 30 + 31 + return stacks, nil 32 + }
+7
internal/version/version.go
··· 1 + package version 2 + 3 + var ( 4 + Version = "dev" 5 + Commit = "unknown" 6 + BuildDate = "unknown" 7 + )
-104
main.go
··· 1 - package main 2 - 3 - import ( 4 - "flag" 5 - "fmt" 6 - "log" 7 - "os" 8 - "path/filepath" 9 - "slices" 10 - "sync" 11 - ) 12 - 13 - func main() { 14 - configPath := flag.String("config", "config.yml", "Path to configuration file") 15 - flag.Parse() 16 - 17 - cfg, err := loadConfig(*configPath) 18 - if err != nil { 19 - log.Fatalf("Failed to load config: %v", err) 20 - } 21 - 22 - // Acquire file lock to prevent concurrent sync jobs 23 - lockPath := filepath.Join(cfg.RepoPath, ".compose-sync.lock") 24 - releaseLock, err := acquireLock(lockPath) 25 - if err != nil { 26 - log.Fatalf("Failed to acquire lock: %v", err) 27 - } 28 - defer func() { 29 - if err := releaseLock(); err != nil { 30 - log.Printf("Warning: failed to release lock: %v", err) 31 - } 32 - }() 33 - 34 - currentHost, err := detectHost() 35 - if err != nil { 36 - log.Fatalf("Failed to detect host: %v", err) 37 - } 38 - fmt.Printf("Detected host: %s\n", currentHost) 39 - 40 - fmt.Printf("Pulling git repository (branch: %s)...\n", cfg.Branch) 41 - changedStacks, err := pullAndDetectChanges(cfg.RepoPath, cfg.Branch) 42 - if err != nil { 43 - log.Fatalf("Failed to pull or detect changes: %v", err) 44 - } 45 - 46 - assignedStacks, err := getAssignedStacks(cfg.RepoPath, currentHost) 47 - if err != nil { 48 - log.Fatalf("Failed to get assigned stacks: %v", err) 49 - } 50 - fmt.Printf("Stacks assigned to this host: %v\n", assignedStacks) 51 - 52 - if len(changedStacks) == 0 { 53 - fmt.Println("No changes detected.") 54 - return 55 - } 56 - 57 - fmt.Printf("Changed stacks: %v\n", changedStacks) 58 - 59 - // Filter to only stacks assigned to this host 60 - stacksToDeploy := []string{} 61 - for _, stack := range changedStacks { 62 - if slices.Contains(assignedStacks, stack) { 63 - stacksToDeploy = append(stacksToDeploy, stack) 64 - } 65 - } 66 - 67 - if len(stacksToDeploy) == 0 { 68 - fmt.Println("No changed stacks are assigned to this host.") 69 - return 70 - } 71 - 72 - fmt.Printf("Stacks to deploy: %v\n", stacksToDeploy) 73 - 74 - deployStacks(cfg.RepoPath, stacksToDeploy, cfg.Concurrency) 75 - } 76 - 77 - func deployStacks(repoPath string, stacks []string, concurrency int) { 78 - // Create a semaphore channel to limit concurrency 79 - semaphore := make(chan struct{}, concurrency) 80 - var wg sync.WaitGroup 81 - 82 - for _, stack := range stacks { 83 - wg.Add(1) 84 - go func(stack string) { 85 - defer wg.Done() 86 - 87 - semaphore <- struct{}{} 88 - defer func() { <-semaphore }() 89 - 90 - composePath := filepath.Join(repoPath, "stacks", stack, "compose.yml") 91 - if _, err := os.Stat(composePath); os.IsNotExist(err) { 92 - composePath = filepath.Join(repoPath, "stacks", stack, "compose.yaml") 93 - } 94 - 95 - fmt.Printf("Deploying stack: %s\n", stack) 96 - if err := deployStack(composePath); err != nil { 97 - log.Printf("Failed to deploy stack %s: %v", stack, err) 98 - return 99 - } 100 - fmt.Printf("Successfully deployed stack: %s\n", stack) 101 - }(stack) 102 - } 103 - wg.Wait() 104 - }
-253
sync.go
··· 1 - package main 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "os" 7 - "os/exec" 8 - "path/filepath" 9 - "strings" 10 - "syscall" 11 - 12 - "gopkg.in/yaml.v3" 13 - ) 14 - 15 - type config struct { 16 - RepoURL string `yaml:"repo_url"` 17 - RepoPath string `yaml:"repo_path"` 18 - Branch string `yaml:"branch"` 19 - Concurrency int `yaml:"concurrency"` 20 - } 21 - 22 - func loadConfig(path string) (*config, error) { 23 - data, err := os.ReadFile(path) 24 - if err != nil { 25 - return nil, fmt.Errorf("failed to read config file: %w", err) 26 - } 27 - 28 - var cfg config 29 - if err := yaml.Unmarshal(data, &cfg); err != nil { 30 - return nil, fmt.Errorf("failed to parse config: %w", err) 31 - } 32 - 33 - if cfg.RepoPath == "" { 34 - return nil, fmt.Errorf("repo_path is required in config") 35 - } 36 - 37 - if cfg.Branch == "" { 38 - cfg.Branch = detectCurrentBranch(cfg.RepoPath) 39 - if cfg.Branch == "" { 40 - cfg.Branch = "main" 41 - } 42 - } 43 - 44 - if cfg.Concurrency <= 0 { 45 - cfg.Concurrency = 3 // Safe default for Docker 46 - } 47 - 48 - return &cfg, nil 49 - } 50 - 51 - func acquireLock(lockPath string) (func() error, error) { 52 - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { 58 - _ = f.Close() 59 - return nil, errors.New("another compose-sync is already running") 60 - } 61 - 62 - return func() error { 63 - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { 64 - f.Close() 65 - return err 66 - } 67 - if err := f.Close(); err != nil { 68 - return err 69 - } 70 - return os.Remove(lockPath) 71 - }, nil 72 - } 73 - 74 - func detectHost() (string, error) { 75 - hostname, err := os.Hostname() 76 - if err != nil { 77 - return "", fmt.Errorf("failed to get hostname: %w", err) 78 - } 79 - return hostname, nil 80 - } 81 - 82 - type inventory struct { 83 - Hosts map[string][]string `yaml:"hosts"` 84 - } 85 - 86 - func getAssignedStacks(repoPath, hostname string) ([]string, error) { 87 - inventoryFile := filepath.Join(repoPath, "inventory.yml") 88 - 89 - data, err := os.ReadFile(inventoryFile) 90 - if err != nil { 91 - return []string{}, fmt.Errorf("failed to read inventory file %s: %w", inventoryFile, err) 92 - } 93 - 94 - var inv inventory 95 - if err := yaml.Unmarshal(data, &inv); err != nil { 96 - return []string{}, fmt.Errorf("failed to parse inventory file %s: %w", inventoryFile, err) 97 - } 98 - 99 - if inv.Hosts == nil { 100 - return []string{}, fmt.Errorf("inventory file %s has no 'hosts' key", inventoryFile) 101 - } 102 - 103 - stacks, exists := inv.Hosts[hostname] 104 - if !exists { 105 - return []string{}, nil // Host not in inventory, no stacks assigned 106 - } 107 - 108 - if stacks == nil { 109 - return []string{}, nil 110 - } 111 - 112 - return stacks, nil 113 - } 114 - 115 - func detectCurrentBranch(repoPath string) string { 116 - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") 117 - cmd.Dir = repoPath 118 - output, err := cmd.Output() 119 - if err != nil { 120 - return "" 121 - } 122 - return strings.TrimSpace(string(output)) 123 - } 124 - 125 - func pullAndDetectChanges(repoPath, branch string) ([]string, error) { 126 - if _, err := os.Stat(repoPath); os.IsNotExist(err) { 127 - return nil, fmt.Errorf("repository path does not exist: %s", repoPath) 128 - } 129 - 130 - if _, err := os.Stat(fmt.Sprintf("%s/.git", repoPath)); os.IsNotExist(err) { 131 - return nil, fmt.Errorf("path is not a git repository: %s", repoPath) 132 - } 133 - 134 - prevHead, err := getGitHead(repoPath) 135 - if err != nil { 136 - return nil, fmt.Errorf("failed to get previous HEAD: %w", err) 137 - } 138 - 139 - if err := gitFetchPull(repoPath, branch); err != nil { 140 - return nil, fmt.Errorf("failed to pull: %w", err) 141 - } 142 - 143 - newHead, err := getGitHead(repoPath) 144 - if err != nil { 145 - return nil, fmt.Errorf("failed to get new HEAD: %w", err) 146 - } 147 - 148 - // If HEAD didnt change, no changes were pulled 149 - if prevHead == newHead { 150 - return []string{}, nil 151 - } 152 - 153 - changedStacks, err := findChangedStacks(repoPath, prevHead, newHead) 154 - if err != nil { 155 - return nil, fmt.Errorf("failed to find changed stacks: %w", err) 156 - } 157 - 158 - return changedStacks, nil 159 - } 160 - 161 - func gitFetchPull(repoPath, branch string) error { 162 - cmd := exec.Command("git", "fetch", "origin", branch) 163 - cmd.Dir = repoPath 164 - if err := cmd.Run(); err != nil { 165 - return fmt.Errorf("git fetch failed: %w", err) 166 - } 167 - 168 - cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") 169 - cmd.Dir = repoPath 170 - currentBranch, _ := cmd.Output() 171 - if strings.TrimSpace(string(currentBranch)) != branch { 172 - cmd = exec.Command("git", "checkout", branch) 173 - cmd.Dir = repoPath 174 - if err := cmd.Run(); err != nil { 175 - return fmt.Errorf("git checkout failed: %w", err) 176 - } 177 - } 178 - 179 - cmd = exec.Command("git", "pull", "origin", branch) 180 - cmd.Dir = repoPath 181 - output, err := cmd.CombinedOutput() 182 - if err != nil { 183 - return fmt.Errorf("git pull failed: %s, %w", string(output), err) 184 - } 185 - return nil 186 - } 187 - 188 - func getGitHead(repoPath string) (string, error) { 189 - cmd := exec.Command("git", "rev-parse", "HEAD") 190 - cmd.Dir = repoPath 191 - output, err := cmd.Output() 192 - if err != nil { 193 - return "", fmt.Errorf("failed to get HEAD: %w", err) 194 - } 195 - return strings.TrimSpace(string(output)), nil 196 - } 197 - 198 - func findChangedStacks(repoPath, oldCommit, newCommit string) ([]string, error) { 199 - // Get list of changed files between the two commits 200 - cmd := exec.Command("git", "diff", "--name-only", oldCommit, newCommit) 201 - cmd.Dir = repoPath 202 - output, err := cmd.Output() 203 - if err != nil { 204 - return nil, fmt.Errorf("failed to get changed files: %w", err) 205 - } 206 - 207 - // Parse changed files and get stack names 208 - changedFiles := strings.Split(strings.TrimSpace(string(output)), "\n") 209 - stackSet := make(map[string]bool) 210 - 211 - for _, file := range changedFiles { 212 - if file == "" { 213 - continue 214 - } 215 - 216 - // Check if file is in the stacks directory 217 - // Format: stacks/<stack-name>/compose.yml 218 - if strings.HasPrefix(file, "stacks/") { 219 - parts := strings.Split(file, "/") 220 - if len(parts) >= 2 { 221 - stackName := parts[1] 222 - // filter out unknown files 223 - if len(parts) >= 3 && (parts[2] == "compose.yml" || parts[2] == "compose.yaml") { 224 - stackSet[stackName] = true 225 - } 226 - } 227 - } 228 - } 229 - 230 - stacks := make([]string, 0, len(stackSet)) 231 - for stack := range stackSet { 232 - stacks = append(stacks, stack) 233 - } 234 - 235 - return stacks, nil 236 - } 237 - 238 - func deployStack(composePath string) error { 239 - if _, err := os.Stat(composePath); os.IsNotExist(err) { 240 - return fmt.Errorf("compose file does not exist: %s", composePath) 241 - } 242 - 243 - composeDir := filepath.Dir(composePath) 244 - 245 - cmd := exec.Command("docker", "compose", "-f", composePath, "up", "-d") 246 - cmd.Dir = composeDir 247 - output, err := cmd.CombinedOutput() 248 - if err != nil { 249 - return fmt.Errorf("docker compose up failed: %s, %w", string(output), err) 250 - } 251 - 252 - return nil 253 - }