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

feat: init repo with current state

Signed-off-by: A. Ottr <alex@otter.foo>

+420 -1
+4 -1
.gitignore
··· 29 30 # Editor/IDE 31 # .idea/ 32 - # .vscode/
··· 29 30 # Editor/IDE 31 # .idea/ 32 + .vscode/ 33 + 34 + compose-sync 35 + config.yml
+103
README.md
···
··· 1 + # compose-sync 2 + 3 + A tool to automatically sync and deploy Docker Compose stacks from a git repository, with multi-host support. 4 + 5 + ## Overview 6 + 7 + compose-sync pulls changes from a git repository containing Docker Compose files and only deploys stacks that: 8 + 1. Have changed since the last pull 9 + 2. Are assigned to the current host 10 + 11 + ## Repository Structure 12 + 13 + Your git repository should have the following structure: 14 + 15 + ``` 16 + stacks/ 17 + traefik/compose.yml 18 + uptime-kuma/compose.yml 19 + home-assistant/compose.yml 20 + ... 21 + 22 + inventory.yml 23 + ``` 24 + 25 + The `inventory.yml` file at the root contains all hosts and their assigned stacks. For example: 26 + 27 + ```yaml 28 + hosts: 29 + vps-1: 30 + - traefik 31 + - uptime-kuma 32 + nas-1: 33 + - home-assistant 34 + - nextcloud 35 + ``` 36 + 37 + This format is compatible with Ansible inventory structures and provides a centralized view of all host assignments. 38 + 39 + ## Installation 40 + 41 + ```bash 42 + go install github.com/aottr/compose-sync@latest 43 + ``` 44 + 45 + #### Alternatively build from source 46 + 47 + 1. Clone this repository 48 + 2. Build the application: 49 + ```bash 50 + go build -o compose-sync 51 + ``` 52 + 53 + ## Configuration 54 + 55 + 1. Copy `config.yml.example` to `config.yml` 56 + 2. Edit `config.yml` and set `repo_path` to the local path of your git repository 57 + 58 + ## Usage 59 + 60 + ### Basic Usage 61 + 62 + ```bash 63 + ./compose-sync 64 + ``` 65 + 66 + This will: 67 + - Detect the current hostname 68 + - Pull the latest changes from git 69 + - Find changed stacks 70 + - Deploy only changed stacks assigned to this host 71 + 72 + ### Dry Run 73 + 74 + To see what would be deployed without actually deploying: 75 + 76 + ```bash 77 + ./compose-sync -dry-run 78 + ``` 79 + 80 + ### Custom Config Path 81 + 82 + ```bash 83 + ./compose-sync -config /path/to/config.yml 84 + ``` 85 + 86 + ## How It Works 87 + 88 + 1. **Host Detection**: The tool uses the system hostname to identify the current host 89 + 2. **Stack Assignment**: Reads `inventory.yml` to determine which stacks should be deployed on this host 90 + 3. **Change Detection**: Compares git commits before and after pulling to find changed stacks 91 + 4. **Selective Deployment**: Only deploys stacks that both changed AND are assigned to this host 92 + 93 + ## Requirements 94 + 95 + - Go 1.21 or later 96 + - Git 97 + - Docker and Docker Compose 98 + - A git repository with the structure described above 99 + 100 + ## License 101 + 102 + MIT 103 +
+4
config.yml.example
···
··· 1 + # Configuration file for compose-sync 2 + repo_url: "git@github.com:user/compose-repo.git" # Optional: URL for cloning (not used if repo_path already exists) 3 + repo_path: "/path/to/local/repo" # Required: Local path to the git repository 4 +
+5
go.mod
···
··· 1 + module github.com/aottr/compose-sync 2 + 3 + go 1.24.5 4 + 5 + require gopkg.in/yaml.v3 v3.0.1
+4
go.sum
···
··· 1 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 2 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 3 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 4 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+95
main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "log" 7 + "os" 8 + "path/filepath" 9 + "slices" 10 + ) 11 + 12 + func main() { 13 + configPath := flag.String("config", "config.yml", "Path to configuration file") 14 + dryRun := flag.Bool("dry-run", false, "Show what would be deployed without actually deploying") 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 + assignedStacks, err := getAssignedStacks(cfg.RepoPath, currentHost) 41 + if err != nil { 42 + log.Fatalf("Failed to get assigned stacks: %v", err) 43 + } 44 + fmt.Printf("Stacks assigned to this host: %v\n", assignedStacks) 45 + 46 + fmt.Println("Pulling git repository...") 47 + changedStacks, err := pullAndDetectChanges(cfg.RepoPath) 48 + if err != nil { 49 + log.Fatalf("Failed to pull or detect changes: %v", err) 50 + } 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 + if *dryRun { 75 + fmt.Println("DRY RUN: Would deploy the following stacks:") 76 + for _, stack := range stacksToDeploy { 77 + fmt.Printf(" - %s\n", stack) 78 + } 79 + return 80 + } 81 + 82 + // Deploy each stack 83 + for _, stack := range stacksToDeploy { 84 + composePath := filepath.Join(cfg.RepoPath, "stacks", stack, "compose.yml") 85 + if _, err := os.Stat(composePath); os.IsNotExist(err) { 86 + composePath = filepath.Join(cfg.RepoPath, "stacks", stack, "compose.yaml") 87 + } 88 + fmt.Printf("Deploying stack: %s\n", stack) 89 + if err := deployStack(composePath); err != nil { 90 + log.Printf("Failed to deploy stack %s: %v", stack, err) 91 + continue 92 + } 93 + fmt.Printf("Successfully deployed stack: %s\n", stack) 94 + } 95 + }
+205
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 + } 19 + 20 + func loadConfig(path string) (*config, error) { 21 + data, err := os.ReadFile(path) 22 + if err != nil { 23 + return nil, fmt.Errorf("failed to read config file: %w", err) 24 + } 25 + 26 + var cfg config 27 + if err := yaml.Unmarshal(data, &cfg); err != nil { 28 + return nil, fmt.Errorf("failed to parse config: %w", err) 29 + } 30 + 31 + if cfg.RepoPath == "" { 32 + return nil, fmt.Errorf("repo_path is required in config") 33 + } 34 + 35 + return &cfg, nil 36 + } 37 + 38 + func acquireLock(lockPath string) (func() error, error) { 39 + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) 40 + if err != nil { 41 + return nil, err 42 + } 43 + 44 + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { 45 + _ = f.Close() 46 + return nil, errors.New("another compose-sync is already running") 47 + } 48 + 49 + return func() error { 50 + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { 51 + f.Close() 52 + return err 53 + } 54 + if err := f.Close(); err != nil { 55 + return err 56 + } 57 + return os.Remove(lockPath) 58 + }, nil 59 + } 60 + 61 + func detectHost() (string, error) { 62 + hostname, err := os.Hostname() 63 + if err != nil { 64 + return "", fmt.Errorf("failed to get hostname: %w", err) 65 + } 66 + return hostname, nil 67 + } 68 + 69 + type inventory struct { 70 + Hosts map[string][]string `yaml:"hosts"` 71 + } 72 + 73 + func getAssignedStacks(repoPath, hostname string) ([]string, error) { 74 + inventoryFile := filepath.Join(repoPath, "inventory.yml") 75 + 76 + data, err := os.ReadFile(inventoryFile) 77 + if err != nil { 78 + return nil, fmt.Errorf("failed to read inventory file %s: %w", inventoryFile, err) 79 + } 80 + 81 + var inv inventory 82 + if err := yaml.Unmarshal(data, &inv); err != nil { 83 + return nil, fmt.Errorf("failed to parse inventory file %s: %w", inventoryFile, err) 84 + } 85 + 86 + stacks, exists := inv.Hosts[hostname] 87 + if !exists { 88 + return []string{}, nil // Host not in inventory, no stacks assigned 89 + } 90 + 91 + return stacks, nil 92 + } 93 + 94 + func pullAndDetectChanges(repoPath string) ([]string, error) { 95 + if _, err := os.Stat(repoPath); os.IsNotExist(err) { 96 + return nil, fmt.Errorf("repository path does not exist: %s", repoPath) 97 + } 98 + 99 + if _, err := os.Stat(fmt.Sprintf("%s/.git", repoPath)); os.IsNotExist(err) { 100 + return nil, fmt.Errorf("path is not a git repository: %s", repoPath) 101 + } 102 + 103 + prevHead, err := getGitHead(repoPath) 104 + if err != nil { 105 + return nil, fmt.Errorf("failed to get previous HEAD: %w", err) 106 + } 107 + 108 + if err := gitPull(repoPath); err != nil { 109 + return nil, fmt.Errorf("failed to pull: %w", err) 110 + } 111 + 112 + newHead, err := getGitHead(repoPath) 113 + if err != nil { 114 + return nil, fmt.Errorf("failed to get new HEAD: %w", err) 115 + } 116 + 117 + // If HEAD didnt change, no changes were pulled 118 + if prevHead == newHead { 119 + return []string{}, nil 120 + } 121 + 122 + changedStacks, err := findChangedStacks(repoPath, prevHead, newHead) 123 + if err != nil { 124 + return nil, fmt.Errorf("failed to find changed stacks: %w", err) 125 + } 126 + 127 + return changedStacks, nil 128 + } 129 + 130 + func gitPull(repoPath string) error { 131 + cmd := exec.Command("git", "pull") 132 + cmd.Dir = repoPath 133 + output, err := cmd.CombinedOutput() 134 + if err != nil { 135 + return fmt.Errorf("git pull failed: %s, %w", string(output), err) 136 + } 137 + return nil 138 + } 139 + 140 + func getGitHead(repoPath string) (string, error) { 141 + cmd := exec.Command("git", "rev-parse", "HEAD") 142 + cmd.Dir = repoPath 143 + output, err := cmd.Output() 144 + if err != nil { 145 + return "", fmt.Errorf("failed to get HEAD: %w", err) 146 + } 147 + return strings.TrimSpace(string(output)), nil 148 + } 149 + 150 + func findChangedStacks(repoPath, oldCommit, newCommit string) ([]string, error) { 151 + // Get list of changed files between the two commits 152 + cmd := exec.Command("git", "diff", "--name-only", oldCommit, newCommit) 153 + cmd.Dir = repoPath 154 + output, err := cmd.Output() 155 + if err != nil { 156 + return nil, fmt.Errorf("failed to get changed files: %w", err) 157 + } 158 + 159 + // Parse changed files and get stack names 160 + changedFiles := strings.Split(strings.TrimSpace(string(output)), "\n") 161 + stackSet := make(map[string]bool) 162 + 163 + for _, file := range changedFiles { 164 + if file == "" { 165 + continue 166 + } 167 + 168 + // Check if file is in the stacks directory 169 + // Format: stacks/<stack-name>/compose.yml 170 + if strings.HasPrefix(file, "stacks/") { 171 + parts := strings.Split(file, "/") 172 + if len(parts) >= 2 { 173 + stackName := parts[1] 174 + // filter out unknown files 175 + if len(parts) >= 3 && (parts[2] == "compose.yml" || parts[2] == "compose.yaml") { 176 + stackSet[stackName] = true 177 + } 178 + } 179 + } 180 + } 181 + 182 + stacks := make([]string, 0, len(stackSet)) 183 + for stack := range stackSet { 184 + stacks = append(stacks, stack) 185 + } 186 + 187 + return stacks, nil 188 + } 189 + 190 + func deployStack(composePath string) error { 191 + if _, err := os.Stat(composePath); os.IsNotExist(err) { 192 + return fmt.Errorf("compose file does not exist: %s", composePath) 193 + } 194 + 195 + composeDir := filepath.Dir(composePath) 196 + 197 + cmd := exec.Command("docker", "compose", "-f", composePath, "up", "-d") 198 + cmd.Dir = composeDir 199 + output, err := cmd.CombinedOutput() 200 + if err != nil { 201 + return fmt.Errorf("docker compose up failed: %s, %w", string(output), err) 202 + } 203 + 204 + return nil 205 + }