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

Configure Feed

Select the types of activity you want to include in your feed.

at 4eee746be1d95d9ee2ace3609cb7b79213cd9e43 205 lines 5.0 kB view raw
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}