Pull-based GitOps-style Docker Compose deployer: polls a (private) Git repo, detects changed stacks and reconciles only the affected
1package main
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "syscall"
11
12 "gopkg.in/yaml.v3"
13)
14
15type config struct {
16 RepoURL string `yaml:"repo_url"`
17 RepoPath string `yaml:"repo_path"`
18}
19
20func 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
38func 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
61func 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
69type inventory struct {
70 Hosts map[string][]string `yaml:"hosts"`
71}
72
73func 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
94func 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
130func 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
140func 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
150func 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
190func 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}