From 88d8b52d8e345cf26075b8757dcda82880498ab6 Mon Sep 17 00:00:00 2001 From: "A. Ottr" Date: Fri, 23 Jan 2026 00:37:18 +0100 Subject: [PATCH] feat: init repo with current state Signed-off-by: A. Ottr --- .gitignore | 5 +- README.md | 103 +++++++++++++++++++++++ config.yml.example | 4 + go.mod | 5 ++ go.sum | 4 + main.go | 95 +++++++++++++++++++++ sync.go | 205 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 config.yml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 sync.go diff --git a/.gitignore b/.gitignore index aaadf73..b8ab85b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,7 @@ go.work.sum # Editor/IDE # .idea/ -# .vscode/ +.vscode/ + +compose-sync +config.yml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b352a7 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# compose-sync + +A tool to automatically sync and deploy Docker Compose stacks from a git repository, with multi-host support. + +## Overview + +compose-sync pulls changes from a git repository containing Docker Compose files and only deploys stacks that: +1. Have changed since the last pull +2. Are assigned to the current host + +## Repository Structure + +Your git repository should have the following structure: + +``` +stacks/ + traefik/compose.yml + uptime-kuma/compose.yml + home-assistant/compose.yml + ... + +inventory.yml +``` + +The `inventory.yml` file at the root contains all hosts and their assigned stacks. For example: + +```yaml +hosts: + vps-1: + - traefik + - uptime-kuma + nas-1: + - home-assistant + - nextcloud +``` + +This format is compatible with Ansible inventory structures and provides a centralized view of all host assignments. + +## Installation + +```bash +go install github.com/aottr/compose-sync@latest +``` + +#### Alternatively build from source + +1. Clone this repository +2. Build the application: + ```bash + go build -o compose-sync + ``` + +## Configuration + +1. Copy `config.yml.example` to `config.yml` +2. Edit `config.yml` and set `repo_path` to the local path of your git repository + +## Usage + +### Basic Usage + +```bash +./compose-sync +``` + +This will: +- Detect the current hostname +- Pull the latest changes from git +- Find changed stacks +- Deploy only changed stacks assigned to this host + +### Dry Run + +To see what would be deployed without actually deploying: + +```bash +./compose-sync -dry-run +``` + +### Custom Config Path + +```bash +./compose-sync -config /path/to/config.yml +``` + +## How It Works + +1. **Host Detection**: The tool uses the system hostname to identify the current host +2. **Stack Assignment**: Reads `inventory.yml` to determine which stacks should be deployed on this host +3. **Change Detection**: Compares git commits before and after pulling to find changed stacks +4. **Selective Deployment**: Only deploys stacks that both changed AND are assigned to this host + +## Requirements + +- Go 1.21 or later +- Git +- Docker and Docker Compose +- A git repository with the structure described above + +## License + +MIT + diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..ef09a39 --- /dev/null +++ b/config.yml.example @@ -0,0 +1,4 @@ +# Configuration file for compose-sync +repo_url: "git@github.com:user/compose-repo.git" # Optional: URL for cloning (not used if repo_path already exists) +repo_path: "/path/to/local/repo" # Required: Local path to the git repository + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..493d792 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/aottr/compose-sync + +go 1.24.5 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8c534e9 --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "slices" +) + +func main() { + configPath := flag.String("config", "config.yml", "Path to configuration file") + dryRun := flag.Bool("dry-run", false, "Show what would be deployed without actually deploying") + flag.Parse() + + cfg, err := loadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Acquire file lock to prevent concurrent sync jobs + lockPath := filepath.Join(cfg.RepoPath, ".compose-sync.lock") + releaseLock, err := acquireLock(lockPath) + if err != nil { + log.Fatalf("Failed to acquire lock: %v", err) + } + defer func() { + if err := releaseLock(); err != nil { + log.Printf("Warning: failed to release lock: %v", err) + } + }() + + currentHost, err := detectHost() + if err != nil { + log.Fatalf("Failed to detect host: %v", err) + } + fmt.Printf("Detected host: %s\n", currentHost) + + assignedStacks, err := getAssignedStacks(cfg.RepoPath, currentHost) + if err != nil { + log.Fatalf("Failed to get assigned stacks: %v", err) + } + fmt.Printf("Stacks assigned to this host: %v\n", assignedStacks) + + fmt.Println("Pulling git repository...") + changedStacks, err := pullAndDetectChanges(cfg.RepoPath) + if err != nil { + log.Fatalf("Failed to pull or detect changes: %v", err) + } + + if len(changedStacks) == 0 { + fmt.Println("No changes detected.") + return + } + + fmt.Printf("Changed stacks: %v\n", changedStacks) + + // Filter to only stacks assigned to this host + stacksToDeploy := []string{} + for _, stack := range changedStacks { + if slices.Contains(assignedStacks, stack) { + stacksToDeploy = append(stacksToDeploy, stack) + } + } + + if len(stacksToDeploy) == 0 { + fmt.Println("No changed stacks are assigned to this host.") + return + } + + fmt.Printf("Stacks to deploy: %v\n", stacksToDeploy) + + if *dryRun { + fmt.Println("DRY RUN: Would deploy the following stacks:") + for _, stack := range stacksToDeploy { + fmt.Printf(" - %s\n", stack) + } + return + } + + // Deploy each stack + for _, stack := range stacksToDeploy { + composePath := filepath.Join(cfg.RepoPath, "stacks", stack, "compose.yml") + if _, err := os.Stat(composePath); os.IsNotExist(err) { + composePath = filepath.Join(cfg.RepoPath, "stacks", stack, "compose.yaml") + } + fmt.Printf("Deploying stack: %s\n", stack) + if err := deployStack(composePath); err != nil { + log.Printf("Failed to deploy stack %s: %v", stack, err) + continue + } + fmt.Printf("Successfully deployed stack: %s\n", stack) + } +} diff --git a/sync.go b/sync.go new file mode 100644 index 0000000..f0c6c68 --- /dev/null +++ b/sync.go @@ -0,0 +1,205 @@ +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "gopkg.in/yaml.v3" +) + +type config struct { + RepoURL string `yaml:"repo_url"` + RepoPath string `yaml:"repo_path"` +} + +func loadConfig(path string) (*config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if cfg.RepoPath == "" { + return nil, fmt.Errorf("repo_path is required in config") + } + + return &cfg, nil +} + +func acquireLock(lockPath string) (func() error, error) { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return nil, err + } + + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + return nil, errors.New("another compose-sync is already running") + } + + return func() error { + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Remove(lockPath) + }, nil +} + +func detectHost() (string, error) { + hostname, err := os.Hostname() + if err != nil { + return "", fmt.Errorf("failed to get hostname: %w", err) + } + return hostname, nil +} + +type inventory struct { + Hosts map[string][]string `yaml:"hosts"` +} + +func getAssignedStacks(repoPath, hostname string) ([]string, error) { + inventoryFile := filepath.Join(repoPath, "inventory.yml") + + data, err := os.ReadFile(inventoryFile) + if err != nil { + return nil, fmt.Errorf("failed to read inventory file %s: %w", inventoryFile, err) + } + + var inv inventory + if err := yaml.Unmarshal(data, &inv); err != nil { + return nil, fmt.Errorf("failed to parse inventory file %s: %w", inventoryFile, err) + } + + stacks, exists := inv.Hosts[hostname] + if !exists { + return []string{}, nil // Host not in inventory, no stacks assigned + } + + return stacks, nil +} + +func pullAndDetectChanges(repoPath string) ([]string, error) { + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + return nil, fmt.Errorf("repository path does not exist: %s", repoPath) + } + + if _, err := os.Stat(fmt.Sprintf("%s/.git", repoPath)); os.IsNotExist(err) { + return nil, fmt.Errorf("path is not a git repository: %s", repoPath) + } + + prevHead, err := getGitHead(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to get previous HEAD: %w", err) + } + + if err := gitPull(repoPath); err != nil { + return nil, fmt.Errorf("failed to pull: %w", err) + } + + newHead, err := getGitHead(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to get new HEAD: %w", err) + } + + // If HEAD didnt change, no changes were pulled + if prevHead == newHead { + return []string{}, nil + } + + changedStacks, err := findChangedStacks(repoPath, prevHead, newHead) + if err != nil { + return nil, fmt.Errorf("failed to find changed stacks: %w", err) + } + + return changedStacks, nil +} + +func gitPull(repoPath string) error { + cmd := exec.Command("git", "pull") + cmd.Dir = repoPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git pull failed: %s, %w", string(output), err) + } + return nil +} + +func getGitHead(repoPath string) (string, error) { + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = repoPath + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get HEAD: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +func findChangedStacks(repoPath, oldCommit, newCommit string) ([]string, error) { + // Get list of changed files between the two commits + cmd := exec.Command("git", "diff", "--name-only", oldCommit, newCommit) + cmd.Dir = repoPath + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get changed files: %w", err) + } + + // Parse changed files and get stack names + changedFiles := strings.Split(strings.TrimSpace(string(output)), "\n") + stackSet := make(map[string]bool) + + for _, file := range changedFiles { + if file == "" { + continue + } + + // Check if file is in the stacks directory + // Format: stacks//compose.yml + if strings.HasPrefix(file, "stacks/") { + parts := strings.Split(file, "/") + if len(parts) >= 2 { + stackName := parts[1] + // filter out unknown files + if len(parts) >= 3 && (parts[2] == "compose.yml" || parts[2] == "compose.yaml") { + stackSet[stackName] = true + } + } + } + } + + stacks := make([]string, 0, len(stackSet)) + for stack := range stackSet { + stacks = append(stacks, stack) + } + + return stacks, nil +} + +func deployStack(composePath string) error { + if _, err := os.Stat(composePath); os.IsNotExist(err) { + return fmt.Errorf("compose file does not exist: %s", composePath) + } + + composeDir := filepath.Dir(composePath) + + cmd := exec.Command("docker", "compose", "-f", composePath, "up", "-d") + cmd.Dir = composeDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker compose up failed: %s, %w", string(output), err) + } + + return nil +} -- 2.51.2