diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fdad2dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Repo Is + +A Docker Compose-based homelab infrastructure project. All services are deployed via Portainer — there are no build, test, or lint commands. Configuration files are the primary artifact. + +## Deployment Model + +- Services live in `services//docker-compose.yml` +- Each service is deployed as a **Portainer stack** by copy-pasting (or uploading) the compose file into the Portainer UI +- **Portainer itself** is managed outside of stacks (deployed manually, not via a stack) +- Secrets and environment variables are injected via Portainer's env var management — never hardcoded (except for existing Traefik credentials, which are a known issue) +- Services that need secrets have a `services//.env.example` documenting the required variables + +## Network Architecture + +All services share an external Docker bridge network named `proxy`. Traefik is the single ingress point listening on `:80`/`:443`, routing to subdomains under `*.home.jens.pub` via Let's Encrypt wildcard certs (DNS-01 challenge via Namecheap API). + +``` +Traefik (proxy network) + ├── traefik.home.jens.pub → Traefik dashboard + ├── adguard.home.jens.pub → AdGuard Home + ├── portainer.home.jens.pub → Portainer + ├── vault.home.jens.pub → Vaultwarden + ├── beszel.home.jens.pub → Beszel metrics hub + └── logs.home.jens.pub → Dozzle +``` + +Beszel agent runs on `host` network (for direct metrics access) and communicates with the hub via a Unix socket at `/var/run/beszel-agent.sock`. + +## Documentation + +`docs/` contains a markdown file per service plus `docs/index.md` as the master overview. **Always update `docs/` when adding or changing a service.** The index lists all services, the network topology, and security notes. + +## Adding a New Service + +1. Create `services//docker-compose.yml` — attach to the `proxy` network, add Traefik labels for routing/TLS +2. If secrets are needed, add `services//.env.example` +3. Add a `docs/.md` with purpose, image, ports, volumes, and config details +4. Update `docs/index.md` to include the new service diff --git a/docs/index.md b/docs/index.md index 10e8388..fa9c320 100644 --- a/docs/index.md +++ b/docs/index.md @@ -86,6 +86,8 @@ The `proxy` network is an **external** bridge network created manually. All serv | `watchtower` | Automatic image updates | [watchtower.md](watchtower.md) | | `beszel` | Container & host metrics | [beszel.md](beszel.md) | | `dozzle` | Container log viewer | [dozzle.md](dozzle.md) | +| `rrr` | Media automation (VPN-routed) | [rrr.md](rrr.md) | +| `jellyfin` | Media server | [jellyfin.md](jellyfin.md) | --- @@ -105,7 +107,12 @@ Internet ├── portainer.home.jens.pub ──→ portainer:9000 ├── vault.home.jens.pub ──→ vaultwarden:80 (password manager) ├── beszel.home.jens.pub ──→ beszel:8090 (metrics) - └── logs.home.jens.pub ──→ dozzle:8080 (log viewer) + ├── logs.home.jens.pub ──→ dozzle:8080 (log viewer) + ├── sonarr.home.jens.pub ──→ gluetun→sonarr:8989 (TV, via Mullvad) + ├── radarr.home.jens.pub ──→ gluetun→radarr:7878 (movies, via Mullvad) + ├── prowlarr.home.jens.pub ──→ gluetun→prowlarr:9696 (indexers, via Mullvad) + ├── sabnzbd.home.jens.pub ──→ gluetun→sabnzbd:8080 (Usenet DL, via Mullvad) + └── jellyfin.home.jens.pub ──→ jellyfin:8096 (media server) [adguard/adguardhome] ──── DNS :53 (TCP/UDP) [portainer/portainer-ee] ──── Portainer UI :9443 diff --git a/docs/jellyfin.md b/docs/jellyfin.md new file mode 100644 index 0000000..2aaa03e --- /dev/null +++ b/docs/jellyfin.md @@ -0,0 +1,42 @@ +# Jellyfin + +**Purpose:** Self-hosted media server for streaming TV and movies from the local library. + +| Property | Value | +| ------------ | ----------------------------------------------------------------------------------- | +| Image | `lscr.io/linuxserver/jellyfin:latest` | +| Web UI | | +| Compose file | [`../services/jellyfin/docker-compose.yml`](../services/jellyfin/docker-compose.yml) | + +--- + +## Environment Variables + +| Variable | Description | +| ------------- | ---------------------------------------- | +| `PUID` | Host user ID for volume ownership | +| `PGID` | Host group ID for volume ownership | +| `TZ` | Timezone (e.g. `Europe/Berlin`) | +| `TV_PATH` | Host path to TV library | +| `MOVIES_PATH` | Host path to movies library | + +--- + +## Volumes + +| Volume | Mount | Purpose | +| ----------------- | ----------------- | ------------------------------ | +| `jellyfin_config` | `/config` | Database, metadata, settings | +| `jellyfin_cache` | `/cache` | Transcoding cache | +| `${TV_PATH}` | `/media/tv:ro` | TV library (read-only) | +| `${MOVIES_PATH}` | `/media/movies:ro`| Movies library (read-only) | + +Media is mounted read-only — Jellyfin only reads, Sonarr/Radarr manage the files. + +--- + +## Networks + +- `proxy` (external) — Traefik routes `jellyfin.home.jens.pub` → port `8096` + +Jellyfin is intentionally **not** routed through the VPN — it serves local clients and doesn't need outbound anonymity. diff --git a/docs/rrr.md b/docs/rrr.md new file mode 100644 index 0000000..1693917 --- /dev/null +++ b/docs/rrr.md @@ -0,0 +1,84 @@ +# rrr — Media Automation Stack + +**Purpose:** Automated media acquisition pipeline (TV + movies) routed entirely through MullvadVPN. + +| Service | Image | Web UI | +| --------- | -------------------------------------- | ---------------------------------------- | +| gluetun | `qmcgaw/gluetun:latest` | — | +| Sonarr | `lscr.io/linuxserver/sonarr:latest` | | +| Radarr | `lscr.io/linuxserver/radarr:latest` | | +| Prowlarr | `lscr.io/linuxserver/prowlarr:latest> | | +| SABnzbd | `lscr.io/linuxserver/sabnzbd:latest` | | +| Compose | [`../services/rrr/docker-compose.yml`](../services/rrr/docker-compose.yml) | | + +--- + +## Architecture + +All traffic from this stack is forced through MullvadVPN via **gluetun**. Sonarr, Radarr, Prowlarr, and NZBGet all set `network_mode: service:gluetun`, which means they share gluetun's network namespace. Any outbound connection from these containers exits through the WireGuard tunnel — they have no direct internet access. + +``` +Traefik (proxy network) + │ + └── gluetun (proxy network + WireGuard tunnel to Mullvad) + │ (shared network namespace via network_mode: service:gluetun) + ├── sonarr :8989 — TV show management + ├── radarr :7878 — Movie management + ├── prowlarr :9696 — Indexer management (feeds Sonarr + Radarr) + └── sabnzbd :8080 — Usenet downloader +``` + +Since all four containers share one network namespace, they communicate with each other via `localhost:`. Use these addresses when configuring integrations: + +| Connection | Address | +| ------------------- | ------------------------- | +| Prowlarr → Sonarr | `http://localhost:8989` | +| Prowlarr → Radarr | `http://localhost:7878` | +| Sonarr → SABnzbd | `http://localhost:8080` | +| Radarr → SABnzbd | `http://localhost:8080` | + +Traefik labels live on gluetun (not the individual apps) because gluetun is the only container attached to the `proxy` network. Each router explicitly references its service with the correct backend port. + +Sonarr, Radarr, and NZBGet all mount the same `DOWNLOADS_PATH` so completed downloads are immediately available for import. + +--- + +## Environment Variables + +Set these in Portainer's stack environment variables when deploying. + +| Variable | Description | +| ---------------------- | ------------------------------------------------------------- | +| `WIREGUARD_PRIVATE_KEY` | WireGuard private key from Mullvad account | +| `WIREGUARD_ADDRESSES` | WireGuard IP assigned by Mullvad (e.g. `10.66.109.243/32`) | +| `SERVER_CITIES` | Optional preferred cities (e.g. `Gothenburg,Stockholm`) | +| `PUID` | Host user ID for volume file ownership (`id -u`) | +| `PGID` | Host group ID for volume file ownership (`id -g`) | +| `TZ` | Timezone (e.g. `Europe/Berlin`) | +| `TV_PATH` | Host path to TV library | +| `MOVIES_PATH` | Host path to movies library | +| `DOWNLOADS_PATH` | Host path to NZBGet downloads directory | + +To get the WireGuard credentials: log in to mullvad.net → Account → WireGuard configuration → Generate key → download the config file. Copy `PrivateKey` and `Address` from that file. + +--- + +## Volumes + +| Volume | Mount | Purpose | +| ---------------- | -------------------- | ------------------------------ | +| `gluetun_data` | `/gluetun` | gluetun state and cert cache | +| `sonarr_config` | `/config` | Sonarr database and settings | +| `radarr_config` | `/config` | Radarr database and settings | +| `prowlarr_config`| `/config` | Prowlarr database and settings | +| `sabnzbd_config` | `/config` | SABnzbd config and scripts | +| `${TV_PATH}` | `/tv` | TV library (Sonarr) | +| `${MOVIES_PATH}` | `/movies` | Movies library (Radarr) | +| `${DOWNLOADS_PATH}` | `/downloads` | Shared download dir (all) | + +--- + +## Networks + +- **gluetun**: `proxy` (external) — only container Traefik can reach +- **sonarr / radarr / prowlarr / nzbget**: `network_mode: service:gluetun` — no independent network attachment diff --git a/knecht/cmd/deploy.go b/knecht/cmd/deploy.go new file mode 100644 index 0000000..974bb20 --- /dev/null +++ b/knecht/cmd/deploy.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + + "github.com/jensbecker/homelab/knecht/portainer" + "github.com/jensbecker/homelab/knecht/stack" + "github.com/spf13/cobra" +) + +var deployCmd = &cobra.Command{ + Use: "deploy ", + Short: "Deploy a new stack from services//docker-compose.yml", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, client, svcPath, err := setup() + if err != nil { + return err + } + + name := args[0] + local, err := stack.Get(svcPath, name) + if err != nil { + return err + } + + // Check if already deployed + existing, err := client.GetStackByName(name) + if err != nil { + return err + } + if existing != nil { + return fmt.Errorf("stack %q already exists (id %d) — use `knecht update %s` instead", name, existing.ID, name) + } + + compose, err := local.ReadCompose() + if err != nil { + return err + } + + // Collect env vars from .env.example keys — values left empty for user to set in Portainer + exampleKeys, err := local.EnvExampleKeys() + if err != nil { + return err + } + env := make([]portainer.EnvVar, len(exampleKeys)) + for i, k := range exampleKeys { + env[i] = portainer.EnvVar{Name: k, Value: ""} + } + + s, err := client.CreateStack(name, compose, env) + if err != nil { + return err + } + fmt.Printf("Deployed stack %q (id %d)\n", s.Name, s.ID) + if len(exampleKeys) > 0 { + fmt.Println("Set the following env vars in Portainer:") + for _, k := range exampleKeys { + fmt.Printf(" • %s\n", k) + } + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(deployCmd) +} diff --git a/knecht/cmd/diff.go b/knecht/cmd/diff.go new file mode 100644 index 0000000..644b16c --- /dev/null +++ b/knecht/cmd/diff.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + + "github.com/jensbecker/homelab/knecht/drift" + "github.com/jensbecker/homelab/knecht/stack" + "github.com/spf13/cobra" +) + +var diffCmd = &cobra.Command{ + Use: "diff ", + Short: "Show drift between local and deployed compose + env keys", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, client, svcPath, err := setup() + if err != nil { + return err + } + + name := args[0] + local, err := stack.Get(svcPath, name) + if err != nil { + return err + } + + remote, err := client.GetStackByName(name) + if err != nil { + return err + } + if remote == nil { + fmt.Printf("Stack %q is not deployed yet.\n", name) + return nil + } + + remoteCompose, err := client.GetStackFile(remote.ID) + if err != nil { + return err + } + localCompose, err := local.ReadCompose() + if err != nil { + return err + } + exampleKeys, err := local.EnvExampleKeys() + if err != nil { + return err + } + + portainerKeys := make([]string, len(remote.Env)) + for i, e := range remote.Env { + portainerKeys[i] = e.Name + } + + // Compose diff + composeDiff := drift.Compose(localCompose, remoteCompose) + fmt.Println("=== compose diff ===") + if len(composeDiff) == 0 { + fmt.Println(" in sync") + } else { + for _, line := range composeDiff { + fmt.Println(" " + line) + } + } + + // Env key diff + missingKeys, unknownKeys := drift.EnvKeys(exampleKeys, portainerKeys) + fmt.Println("\n=== env keys ===") + if len(missingKeys) == 0 && len(unknownKeys) == 0 { + fmt.Println(" in sync") + } + for _, k := range missingKeys { + fmt.Printf(" ! missing in Portainer: %s\n", k) + } + for _, k := range unknownKeys { + fmt.Printf(" ? unknown (not in .env.example): %s\n", k) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(diffCmd) +} diff --git a/knecht/cmd/list.go b/knecht/cmd/list.go new file mode 100644 index 0000000..f203b28 --- /dev/null +++ b/knecht/cmd/list.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "fmt" + + "github.com/jensbecker/homelab/knecht/drift" + "github.com/jensbecker/homelab/knecht/portainer" + "github.com/jensbecker/homelab/knecht/stack" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all stacks with status and drift", + RunE: func(cmd *cobra.Command, args []string) error { + _, client, svcPath, err := setup() + if err != nil { + return err + } + + stacks, err := client.ListStacks() + if err != nil { + return err + } + + locals, err := stack.Discover(svcPath) + if err != nil { + return err + } + localByName := make(map[string]stack.Local, len(locals)) + for _, l := range locals { + localByName[l.Name] = l + } + + fmt.Printf("%-20s %-10s %s\n", "STACK", "STATUS", "DRIFT") + fmt.Println(repeat("-", 60)) + + for _, s := range stacks { + status := statusLabel(s.Status) + driftSummary := "no local compose" + + local, ok := localByName[s.Name] + if ok { + driftSummary, err = computeDriftSummary(client, &s, &local) + if err != nil { + driftSummary = "error: " + err.Error() + } + } + + fmt.Printf("%-20s %-10s %s\n", s.Name, status, driftSummary) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(listCmd) +} + +func statusLabel(status int) string { + if status == 1 { + return "running" + } + return "stopped" +} + +func computeDriftSummary(client *portainer.Client, s *portainer.Stack, local *stack.Local) (string, error) { + remoteCompose, err := client.GetStackFile(s.ID) + if err != nil { + return "", err + } + localCompose, err := local.ReadCompose() + if err != nil { + return "", err + } + exampleKeys, err := local.EnvExampleKeys() + if err != nil { + return "", err + } + + portainerKeys := make([]string, len(s.Env)) + for i, e := range s.Env { + portainerKeys[i] = e.Name + } + + composeDiff := drift.Compose(localCompose, remoteCompose) + missingKeys, unknownKeys := drift.EnvKeys(exampleKeys, portainerKeys) + + r := drift.Result{ + ComposeDiff: composeDiff, + MissingKeys: missingKeys, + UnknownKeys: unknownKeys, + } + return r.Summary(), nil +} + +func repeat(s string, n int) string { + out := "" + for i := 0; i < n; i++ { + out += s + } + return out +} diff --git a/knecht/cmd/logs.go b/knecht/cmd/logs.go new file mode 100644 index 0000000..9fc9b03 --- /dev/null +++ b/knecht/cmd/logs.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs", + Short: "Open the Dozzle log viewer in the browser", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, _, err := setup() + if err != nil { + return err + } + + // Derive logs URL from Portainer URL (same base domain) + // e.g. https://portainer.home.jens.pub → https://logs.home.jens.pub + logsURL := deriveLogsURL(cfg.Portainer.URL) + fmt.Printf("Opening %s\n", logsURL) + return openBrowser(logsURL) + }, +} + +func init() { + rootCmd.AddCommand(logsCmd) +} + +func deriveLogsURL(portainerURL string) string { + // Replace "portainer." prefix with "logs." + return fmt.Sprintf("https://logs.%s", domainFromURL(portainerURL)) +} + +func domainFromURL(u string) string { + // Strip scheme and find domain after first dot + u = stripScheme(u) + for i, c := range u { + if c == '.' { + return u[i+1:] + } + } + return u +} + +func stripScheme(u string) string { + for _, prefix := range []string{"https://", "http://"} { + if len(u) > len(prefix) && u[:len(prefix)] == prefix { + return u[len(prefix):] + } + } + return u +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return cmd.Start() +} diff --git a/knecht/cmd/root.go b/knecht/cmd/root.go new file mode 100644 index 0000000..c37fcb5 --- /dev/null +++ b/knecht/cmd/root.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/jensbecker/homelab/knecht/config" + "github.com/jensbecker/homelab/knecht/portainer" + "github.com/jensbecker/homelab/knecht/stack" + "github.com/jensbecker/homelab/knecht/tui" + "github.com/spf13/cobra" +) + +var servicesPathFlag string + +var rootCmd = &cobra.Command{ + Use: "knecht", + Short: "Homelab stack manager", + Long: "knecht manages Portainer stacks from your local services/ directory.", + // No subcommand → launch TUI + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, svcPath, err := setup() + if err != nil { + return err + } + return tui.Run(client, svcPath, cfg) + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&servicesPathFlag, "services", "", "path to services directory (default: auto-detected from git root)") +} + +// setup loads config, builds the Portainer client, and resolves the services path. +func setup() (*config.Config, *portainer.Client, string, error) { + cfg, err := config.Load() + if err != nil { + return nil, nil, "", err + } + + client, err := portainer.New(cfg.Portainer.URL, cfg.Portainer.Token, cfg.Portainer.Endpoint) + if err != nil { + return nil, nil, "", fmt.Errorf("connecting to Portainer: %w", err) + } + + svcPath, err := stack.ServicesPath(servicesPathFlag) + if svcPath == "" && err == nil { + svcPath, err = stack.ServicesPath(cfg.ServicesPath) + } + if err != nil { + return nil, nil, "", err + } + + return cfg, client, svcPath, nil +} diff --git a/knecht/cmd/update.go b/knecht/cmd/update.go new file mode 100644 index 0000000..4459e73 --- /dev/null +++ b/knecht/cmd/update.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + + "github.com/jensbecker/homelab/knecht/portainer" + "github.com/jensbecker/homelab/knecht/stack" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an existing stack, preserving env vars", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, client, svcPath, err := setup() + if err != nil { + return err + } + + name := args[0] + local, err := stack.Get(svcPath, name) + if err != nil { + return err + } + + remote, err := client.GetStackByName(name) + if err != nil { + return err + } + if remote == nil { + return fmt.Errorf("stack %q not found — use `knecht deploy %s` to create it", name, name) + } + + compose, err := local.ReadCompose() + if err != nil { + return err + } + + // Check for missing env keys and prompt for values + exampleKeys, err := local.EnvExampleKeys() + if err != nil { + return err + } + env, err := mergeEnvVars(remote.Env, exampleKeys) + if err != nil { + return err + } + + s, err := client.UpdateStack(remote.ID, compose, env) + if err != nil { + return err + } + fmt.Printf("Updated stack %q (id %d)\n", s.Name, s.ID) + return nil + }, +} + +// mergeEnvVars takes existing Portainer env vars and prompts for any keys +// present in .env.example but missing from Portainer. +func mergeEnvVars(existing []portainer.EnvVar, exampleKeys []string) ([]portainer.EnvVar, error) { + envMap := make(map[string]string, len(existing)) + for _, e := range existing { + envMap[e.Name] = e.Value + } + + for _, key := range exampleKeys { + if _, ok := envMap[key]; !ok { + fmt.Printf("Missing env var %q — enter value (leave empty to skip): ", key) + var val string + fmt.Scanln(&val) + envMap[key] = val + } + } + + result := make([]portainer.EnvVar, 0, len(envMap)) + for k, v := range envMap { + result = append(result, portainer.EnvVar{Name: k, Value: v}) + } + return result, nil +} + +func init() { + rootCmd.AddCommand(updateCmd) +} diff --git a/knecht/config/config.go b/knecht/config/config.go new file mode 100644 index 0000000..ced5886 --- /dev/null +++ b/knecht/config/config.go @@ -0,0 +1,56 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +type Portainer struct { + URL string `toml:"url"` + Token string `toml:"token"` + Endpoint string `toml:"endpoint"` // optional name override, defaults to auto-discover +} + +type Config struct { + Portainer Portainer `toml:"portainer"` + ServicesPath string `toml:"services_path"` // optional, defaults to git root / services +} + +func Load() (*Config, error) { + path, err := configPath() + if err != nil { + return nil, err + } + + var cfg Config + if _, err := toml.DecodeFile(path, &cfg); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("config file not found at %s — run `knecht init` to create one", path) + } + return nil, fmt.Errorf("parsing config: %w", err) + } + + if cfg.Portainer.URL == "" { + return nil, fmt.Errorf("portainer.url is required in %s", path) + } + if cfg.Portainer.Token == "" { + return nil, fmt.Errorf("portainer.token is required in %s", path) + } + + return &cfg, nil +} + +func Path() (string, error) { + return configPath() +} + +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "knecht", "config.toml"), nil +} diff --git a/knecht/drift/drift.go b/knecht/drift/drift.go new file mode 100644 index 0000000..4120ffe --- /dev/null +++ b/knecht/drift/drift.go @@ -0,0 +1,102 @@ +package drift + +import ( + "fmt" + "strings" +) + +type Result struct { + ComposeDiff []string // unified-style diff lines + MissingKeys []string // in .env.example but not in Portainer + UnknownKeys []string // in Portainer but not in .env.example (file exists) + PortainerOnlyKeys []string // Portainer has keys but no .env.example exists at all +} + +func (r *Result) HasDrift() bool { + return len(r.ComposeDiff) > 0 || len(r.MissingKeys) > 0 || len(r.UnknownKeys) > 0 || len(r.PortainerOnlyKeys) > 0 +} + +func (r *Result) Summary() string { + parts := []string{} + if len(r.ComposeDiff) > 0 { + parts = append(parts, fmt.Sprintf("compose ~%d lines", len(r.ComposeDiff))) + } + if len(r.MissingKeys) > 0 { + parts = append(parts, fmt.Sprintf("%d env key(s) missing", len(r.MissingKeys))) + } + if len(r.UnknownKeys) > 0 { + parts = append(parts, fmt.Sprintf("%d env key(s) unknown", len(r.UnknownKeys))) + } + if len(r.PortainerOnlyKeys) > 0 { + parts = append(parts, fmt.Sprintf("no .env.example (%d key(s) in Portainer)", len(r.PortainerOnlyKeys))) + } + if len(parts) == 0 { + return "in sync" + } + return strings.Join(parts, ", ") +} + +// Compose diffs local vs remote compose content line by line. +func Compose(local, remote string) []string { + localLines := strings.Split(strings.TrimRight(local, "\n"), "\n") + remoteLines := strings.Split(strings.TrimRight(remote, "\n"), "\n") + + if local == remote { + return nil + } + + // Simple line-by-line diff: mark additions and removals. + remoteSet := toSet(remoteLines) + localSet := toSet(localLines) + + var diff []string + for _, l := range remoteLines { + if !localSet[l] { + diff = append(diff, "- "+l) + } + } + for _, l := range localLines { + if !remoteSet[l] { + diff = append(diff, "+ "+l) + } + } + return diff +} + +// EnvKeys compares keys from .env.example against keys set in Portainer. +// If exampleKeys is nil (no .env.example file), both slices are returned nil — +// use PortainerOnlyKeys on the Result instead to handle that case. +func EnvKeys(exampleKeys []string, portainerKeys []string) (missing, unknown []string) { + if exampleKeys == nil { + return nil, nil + } + + portainerSet := make(map[string]bool, len(portainerKeys)) + for _, k := range portainerKeys { + portainerSet[k] = true + } + exampleSet := make(map[string]bool, len(exampleKeys)) + for _, k := range exampleKeys { + exampleSet[k] = true + } + + for _, k := range exampleKeys { + if !portainerSet[k] { + missing = append(missing, k) + } + } + for _, k := range portainerKeys { + if !exampleSet[k] { + unknown = append(unknown, k) + } + } + return +} + +func toSet(lines []string) map[string]bool { + s := make(map[string]bool, len(lines)) + for _, l := range lines { + s[l] = true + } + return s +} diff --git a/knecht/go.mod b/knecht/go.mod new file mode 100644 index 0000000..eb25c6f --- /dev/null +++ b/knecht/go.mod @@ -0,0 +1,35 @@ +module github.com/jensbecker/homelab/knecht + +go 1.25.6 + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/knecht/go.sum b/knecht/go.sum new file mode 100644 index 0000000..e9ba8b0 --- /dev/null +++ b/knecht/go.sum @@ -0,0 +1,64 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/knecht/main.go b/knecht/main.go new file mode 100644 index 0000000..e04d5bf --- /dev/null +++ b/knecht/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/jensbecker/homelab/knecht/cmd" + +func main() { + cmd.Execute() +} diff --git a/knecht/portainer/client.go b/knecht/portainer/client.go new file mode 100644 index 0000000..3a30ee6 --- /dev/null +++ b/knecht/portainer/client.go @@ -0,0 +1,199 @@ +package portainer + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type Client struct { + baseURL string + token string + httpClient *http.Client + endpointID int +} + +type Endpoint struct { + ID int `json:"Id"` + Name string `json:"Name"` +} + +type EnvVar struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type Stack struct { + ID int `json:"Id"` + Name string `json:"Name"` + Status int `json:"Status"` // 1 = active, 2 = inactive + EndpointID int `json:"EndpointId"` + Env []EnvVar `json:"Env"` +} + +type StackFile struct { + StackFileContent string `json:"StackFileContent"` +} + +func New(baseURL, token, endpointName string) (*Client, error) { + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: &http.Client{}, + } + + id, err := c.discoverEndpoint(endpointName) + if err != nil { + return nil, err + } + c.endpointID = id + return c, nil +} + +func (c *Client) EndpointID() int { + return c.endpointID +} + +func (c *Client) discoverEndpoint(name string) (int, error) { + var endpoints []Endpoint + if err := c.get("/api/endpoints", &endpoints); err != nil { + return 0, fmt.Errorf("listing endpoints: %w", err) + } + if len(endpoints) == 0 { + return 0, fmt.Errorf("no endpoints found in Portainer") + } + // If a name is specified, match by name; otherwise use the first active one. + for _, e := range endpoints { + if name == "" || e.Name == name { + return e.ID, nil + } + } + return 0, fmt.Errorf("endpoint %q not found", name) +} + +func (c *Client) ListStacks() ([]Stack, error) { + var stacks []Stack + if err := c.get("/api/stacks", &stacks); err != nil { + return nil, err + } + return stacks, nil +} + +func (c *Client) GetStack(id int) (*Stack, error) { + var s Stack + if err := c.get(fmt.Sprintf("/api/stacks/%d", id), &s); err != nil { + return nil, err + } + return &s, nil +} + +func (c *Client) GetStackByName(name string) (*Stack, error) { + stacks, err := c.ListStacks() + if err != nil { + return nil, err + } + for _, s := range stacks { + if s.Name == name { + return &s, nil + } + } + return nil, nil // not found, not an error +} + +func (c *Client) GetStackFile(id int) (string, error) { + var f StackFile + if err := c.get(fmt.Sprintf("/api/stacks/%d/file", id), &f); err != nil { + return "", err + } + return f.StackFileContent, nil +} + +func (c *Client) CreateStack(name, composeContent string, env []EnvVar) (*Stack, error) { + body := map[string]any{ + "name": name, + "stackFileContent": composeContent, + "env": env, + } + var s Stack + if err := c.post(fmt.Sprintf("/api/stacks/create/standalone/string?endpointId=%d", c.endpointID), body, &s); err != nil { + return nil, err + } + return &s, nil +} + +func (c *Client) UpdateStack(id int, composeContent string, env []EnvVar) (*Stack, error) { + body := map[string]any{ + "stackFileContent": composeContent, + "env": env, + "prune": true, + "pullImage": false, + } + var s Stack + if err := c.put(fmt.Sprintf("/api/stacks/%d?endpointId=%d", id, c.endpointID), body, &s); err != nil { + return nil, err + } + return &s, nil +} + +// request helpers + +func (c *Client) get(path string, out any) error { + resp, err := c.do("GET", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) post(path string, body, out any) error { + resp, err := c.do("POST", path, body) + if err != nil { + return err + } + defer resp.Body.Close() + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) put(path string, body, out any) error { + resp, err := c.do("PUT", path, body) + if err != nil { + return err + } + defer resp.Body.Close() + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) do(method, path string, body any) (*http.Response, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, c.baseURL+path, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("X-API-Key", c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("portainer API %s %s: %s — %s", method, path, resp.Status, string(b)) + } + return resp, nil +} diff --git a/knecht/stack/stack.go b/knecht/stack/stack.go new file mode 100644 index 0000000..346b8df --- /dev/null +++ b/knecht/stack/stack.go @@ -0,0 +1,144 @@ +package stack + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +type Local struct { + Name string + ComposePath string + EnvExample string // path to .env.example, may not exist +} + +// Discover finds all stacks in the services directory. +func Discover(servicesPath string) ([]Local, error) { + entries, err := os.ReadDir(servicesPath) + if err != nil { + return nil, fmt.Errorf("reading services dir %s: %w", servicesPath, err) + } + + var stacks []Local + for _, e := range entries { + if !e.IsDir() { + continue + } + composePath := filepath.Join(servicesPath, e.Name(), "docker-compose.yml") + if _, err := os.Stat(composePath); err != nil { + continue // no compose file, skip + } + s := Local{ + Name: e.Name(), + ComposePath: composePath, + EnvExample: filepath.Join(servicesPath, e.Name(), ".env.example"), + } + stacks = append(stacks, s) + } + return stacks, nil +} + +// Get returns a single local stack by name. +func Get(servicesPath, name string) (*Local, error) { + composePath := filepath.Join(servicesPath, name, "docker-compose.yml") + if _, err := os.Stat(composePath); err != nil { + return nil, fmt.Errorf("no docker-compose.yml found for stack %q in %s", name, servicesPath) + } + return &Local{ + Name: name, + ComposePath: composePath, + EnvExample: filepath.Join(servicesPath, name, ".env.example"), + }, nil +} + +// ReadCompose returns the raw compose file content. +func (s *Local) ReadCompose() (string, error) { + b, err := os.ReadFile(s.ComposePath) + if err != nil { + return "", err + } + return string(b), nil +} + +// EnvExampleKeys returns the variable names defined in .env.example. +// Returns empty slice if the file doesn't exist. +func (s *Local) EnvExampleKeys() ([]string, error) { + f, err := os.Open(s.EnvExample) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + + var keys []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, _, _ := strings.Cut(line, "=") + keys = append(keys, strings.TrimSpace(key)) + } + return keys, scanner.Err() +} + +// WriteEnvExample creates a .env.example file from a list of keys (values left empty). +// Existing file is overwritten. +func (s *Local) WriteEnvExample(keys []string) error { + var sb strings.Builder + sb.WriteString("# Generated by knecht from Portainer env vars\n") + for _, k := range keys { + sb.WriteString(k + "=\n") + } + return os.WriteFile(s.EnvExample, []byte(sb.String()), 0644) +} + +// AppendEnvExample appends missing keys to an existing .env.example file. +func (s *Local) AppendEnvExample(keys []string) error { + f, err := os.OpenFile(s.EnvExample, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + f.WriteString("\n# Added by knecht\n") + for _, k := range keys { + f.WriteString(k + "=\n") + } + return nil +} + +// ServicesPath resolves the services directory: uses cfg value if set, +// otherwise walks up from cwd to find the git root and appends /services. +func ServicesPath(cfgPath string) (string, error) { + if cfgPath != "" { + return cfgPath, nil + } + return findServicesDir() +} + +func findServicesDir() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + p := filepath.Join(dir, "services") + if _, err := os.Stat(p); err == nil { + return p, nil + } + return "", fmt.Errorf("found git root at %s but no services/ directory", dir) + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("could not find git root with a services/ directory") +} diff --git a/knecht/tui/tui.go b/knecht/tui/tui.go new file mode 100644 index 0000000..6d420b9 --- /dev/null +++ b/knecht/tui/tui.go @@ -0,0 +1,421 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/jensbecker/homelab/knecht/config" + "github.com/jensbecker/homelab/knecht/drift" + "github.com/jensbecker/homelab/knecht/portainer" + "github.com/jensbecker/homelab/knecht/stack" +) + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + successStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")) + mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + driftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + removeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) +) + +type viewMode int + +const ( + modeSummary viewMode = iota + modeDiff +) + +// ── messages ───────────────────────────────────────────────────────────────── + +type stacksLoadedMsg []stackItem +type updateDoneMsg struct{ err error } +type envExampleWrittenMsg struct{ err error } +type errMsg error + +// ── list item ──────────────────────────────────────────────────────────────── + +type stackItem struct { + remote portainer.Stack + local *stack.Local + driftResult *drift.Result +} + +func (s stackItem) Title() string { + name := s.remote.Name + if s.driftResult != nil && s.driftResult.HasDrift() { + name += driftStyle.Render(" ~") + } + return name +} + +func (s stackItem) Description() string { + status := "running" + if s.remote.Status != 1 { + status = mutedStyle.Render("stopped") + } + if s.driftResult == nil { + return status + " · no local compose" + } + return status + " · " + s.driftResult.Summary() +} + +func (s stackItem) FilterValue() string { return s.remote.Name } + +// ── model ──────────────────────────────────────────────────────────────────── + +type model struct { + list list.Model + spinner spinner.Model + client *portainer.Client + svcPath string + mode viewMode + updating bool + status string // transient success/error message + width int + height int +} + +func initialModel(client *portainer.Client, svcPath string) model { + l := list.New(nil, list.NewDefaultDelegate(), 0, 0) + l.Title = "knecht" + l.Styles.Title = titleStyle + l.SetShowHelp(false) // we render our own key hints + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + + return model{ + list: l, + spinner: s, + client: client, + svcPath: svcPath, + } +} + +// ── commands ────────────────────────────────────────────────────────────────── + +func loadStacks(client *portainer.Client, svcPath string) tea.Cmd { + return func() tea.Msg { + remotes, err := client.ListStacks() + if err != nil { + return errMsg(err) + } + + locals, _ := stack.Discover(svcPath) + localByName := make(map[string]stack.Local, len(locals)) + for _, l := range locals { + localByName[l.Name] = l + } + + items := make([]stackItem, 0, len(remotes)) + for _, r := range remotes { + item := stackItem{remote: r} + if l, ok := localByName[r.Name]; ok { + lCopy := l + item.local = &lCopy + item.driftResult = computeDrift(client, &r, &lCopy) + } + items = append(items, item) + } + return stacksLoadedMsg(items) + } +} + +func writeEnvExample(s stackItem) tea.Cmd { + return func() tea.Msg { + if s.local == nil || s.driftResult == nil { + return envExampleWrittenMsg{fmt.Errorf("no local stack info")} + } + // No .env.example at all — create from scratch + if len(s.driftResult.PortainerOnlyKeys) > 0 { + return envExampleWrittenMsg{s.local.WriteEnvExample(s.driftResult.PortainerOnlyKeys)} + } + // .env.example exists but has unknown keys — append them + if len(s.driftResult.UnknownKeys) > 0 { + return envExampleWrittenMsg{s.local.AppendEnvExample(s.driftResult.UnknownKeys)} + } + return envExampleWrittenMsg{fmt.Errorf("nothing to write")} + } +} + +func updateStack(client *portainer.Client, s stackItem) tea.Cmd { + return func() tea.Msg { + compose, err := s.local.ReadCompose() + if err != nil { + return updateDoneMsg{err} + } + _, err = client.UpdateStack(s.remote.ID, compose, s.remote.Env) + return updateDoneMsg{err} + } +} + +// ── bubbletea ──────────────────────────────────────────────────────────────── + +func (m model) Init() tea.Cmd { + return tea.Batch(loadStacks(m.client, m.svcPath), m.spinner.Tick) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.list.SetSize(msg.Width/2, msg.Height-4) + + case stacksLoadedMsg: + m.updating = false + items := make([]list.Item, len(msg)) + for i, s := range msg { + sCopy := s + items[i] = sCopy + } + m.list.SetItems(items) + + case updateDoneMsg: + m.updating = false + if msg.err != nil { + m.status = errorStyle.Render("Update failed: " + msg.err.Error()) + } else { + m.status = successStyle.Render("Updated successfully — refreshing...") + cmds = append(cmds, loadStacks(m.client, m.svcPath)) + } + + case envExampleWrittenMsg: + if msg.err != nil { + m.status = errorStyle.Render("Failed to write .env.example: " + msg.err.Error()) + } else { + m.status = successStyle.Render(".env.example created — refreshing...") + cmds = append(cmds, loadStacks(m.client, m.svcPath)) + } + + case errMsg: + m.updating = false + m.status = errorStyle.Render("Error: " + msg.Error()) + + case spinner.TickMsg: + if m.updating { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + + case tea.KeyMsg: + // Don't pass keys to the list while filtering is active + if m.list.SettingFilter() { + break + } + + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "r": + m.status = "" + m.mode = modeSummary + cmds = append(cmds, loadStacks(m.client, m.svcPath)) + return m, tea.Batch(cmds...) + + case "d": + if m.mode == modeDiff { + m.mode = modeSummary + } else { + m.mode = modeDiff + } + return m, nil + + case "u": + item, ok := m.list.SelectedItem().(stackItem) + if !ok || item.local == nil || m.updating { + return m, nil + } + m.updating = true + m.status = "" + m.mode = modeSummary + cmds = append(cmds, updateStack(m.client, item), m.spinner.Tick) + return m, tea.Batch(cmds...) + + case "e": + item, ok := m.list.SelectedItem().(stackItem) + if !ok || item.driftResult == nil { + return m, nil + } + hasEnvDrift := len(item.driftResult.PortainerOnlyKeys) > 0 || len(item.driftResult.UnknownKeys) > 0 + if !hasEnvDrift { + return m, nil + } + cmds = append(cmds, writeEnvExample(item)) + return m, tea.Batch(cmds...) + + case "esc": + m.mode = modeSummary + m.status = "" + return m, nil + } + } + + var listCmd tea.Cmd + m.list, listCmd = m.list.Update(msg) + cmds = append(cmds, listCmd) + + return m, tea.Batch(cmds...) +} + +// ── views ───────────────────────────────────────────────────────────────────── + +func (m model) View() string { + left := m.list.View() + + var detail string + if m.updating { + detail = m.spinner.View() + " Updating..." + } else if item, ok := m.list.SelectedItem().(stackItem); ok { + switch m.mode { + case modeDiff: + detail = renderDiff(item) + default: + detail = renderSummary(item) + } + } + + if m.status != "" { + detail += "\n\n" + m.status + } + + right := lipgloss.NewStyle(). + Width(m.width/2 - 2). + Height(m.height - 4). + Padding(1, 2). + Border(lipgloss.RoundedBorder()). + Render(detail) + + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} + +func renderSummary(s stackItem) string { + out := titleStyle.Render(s.remote.Name) + "\n\n" + + if s.remote.Status == 1 { + out += fmt.Sprintf("status: %s\n", successStyle.Render("running")) + } else { + out += fmt.Sprintf("status: %s\n", mutedStyle.Render("stopped")) + } + + if s.driftResult == nil { + out += mutedStyle.Render("no local compose file\n") + return out + } + + out += fmt.Sprintf("drift: %s\n", s.driftResult.Summary()) + + if len(s.driftResult.MissingKeys) > 0 { + out += "\n" + driftStyle.Render("missing env keys:") + "\n" + for _, k := range s.driftResult.MissingKeys { + out += fmt.Sprintf(" ! %s\n", k) + } + } + if len(s.driftResult.UnknownKeys) > 0 { + out += "\n" + mutedStyle.Render("unknown env keys (in Portainer, not in .env.example):") + "\n" + for _, k := range s.driftResult.UnknownKeys { + out += fmt.Sprintf(" ? %s\n", k) + } + out += mutedStyle.Render(" [e] append to .env.example") + "\n" + } + if len(s.driftResult.PortainerOnlyKeys) > 0 { + out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n" + for _, k := range s.driftResult.PortainerOnlyKeys { + out += fmt.Sprintf(" • %s\n", k) + } + out += mutedStyle.Render(" [e] generate .env.example") + "\n" + } + + out += "\n" + mutedStyle.Render("[u] update [d] diff [r] refresh [esc] back [q] quit") + return out +} + +func renderDiff(s stackItem) string { + out := titleStyle.Render(s.remote.Name+" — diff") + "\n\n" + + if s.driftResult == nil { + return out + mutedStyle.Render("no local compose file") + } + + // Compose diff + out += mutedStyle.Render("compose:") + "\n" + if len(s.driftResult.ComposeDiff) == 0 { + out += " " + successStyle.Render("in sync") + "\n" + } else { + for _, line := range s.driftResult.ComposeDiff { + if strings.HasPrefix(line, "+") { + out += " " + addStyle.Render(line) + "\n" + } else { + out += " " + removeStyle.Render(line) + "\n" + } + } + } + + // Env key diff + out += "\n" + mutedStyle.Render("env keys:") + "\n" + if len(s.driftResult.MissingKeys) == 0 && len(s.driftResult.UnknownKeys) == 0 { + out += " " + successStyle.Render("in sync") + "\n" + } + for _, k := range s.driftResult.MissingKeys { + out += " " + driftStyle.Render("! missing: "+k) + "\n" + } + for _, k := range s.driftResult.UnknownKeys { + out += " " + mutedStyle.Render("? unknown: "+k) + "\n" + } + if len(s.driftResult.PortainerOnlyKeys) > 0 { + out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n" + for _, k := range s.driftResult.PortainerOnlyKeys { + out += fmt.Sprintf(" • %s\n", k) + } + } + + out += "\n" + mutedStyle.Render("[d] back [u] update [e] generate .env.example [q] quit") + return out +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Local) *drift.Result { + remoteCompose, err := client.GetStackFile(s.ID) + if err != nil { + return nil + } + localCompose, _ := local.ReadCompose() + exampleKeys, _ := local.EnvExampleKeys() + + portainerKeys := make([]string, len(s.Env)) + for i, e := range s.Env { + portainerKeys[i] = e.Name + } + + result := &drift.Result{ + ComposeDiff: drift.Compose(localCompose, remoteCompose), + } + + if exampleKeys == nil && len(portainerKeys) > 0 { + // No .env.example but Portainer has keys — surface as its own drift type + result.PortainerOnlyKeys = portainerKeys + } else { + result.MissingKeys, result.UnknownKeys = drift.EnvKeys(exampleKeys, portainerKeys) + } + + return result +} + +func Run(client *portainer.Client, svcPath string, _ *config.Config) error { + p := tea.NewProgram(initialModel(client, svcPath), tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/services/jellyfin/.env.example b/services/jellyfin/.env.example new file mode 100644 index 0000000..79475a5 --- /dev/null +++ b/services/jellyfin/.env.example @@ -0,0 +1,6 @@ +# Generated by knecht from Portainer env vars +PUID= +PGID= +TZ= +TV_PATH= +MOVIES_PATH= diff --git a/services/jellyfin/docker-compose.yml b/services/jellyfin/docker-compose.yml new file mode 100644 index 0000000..4be49b8 --- /dev/null +++ b/services/jellyfin/docker-compose.yml @@ -0,0 +1,29 @@ +services: + jellyfin: + image: lscr.io/linuxserver/jellyfin:latest + container_name: jellyfin + restart: unless-stopped + environment: + - PUID=${PUID} + - PGID=${PGID} + - TZ=${TZ} + volumes: + - jellyfin_config:/config + - jellyfin_cache:/cache + - ${TV_PATH}:/media/tv:ro + - ${MOVIES_PATH}:/media/movies:ro + networks: + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.jellyfin.rule=Host(`jellyfin.home.jens.pub`)" + - "traefik.http.routers.jellyfin.entrypoints=websecure" + - "traefik.http.services.jellyfin.loadbalancer.server.port=8096" + +volumes: + jellyfin_config: + jellyfin_cache: + +networks: + proxy: + external: true diff --git a/services/rrr/.env.example b/services/rrr/.env.example new file mode 100644 index 0000000..432b5e5 --- /dev/null +++ b/services/rrr/.env.example @@ -0,0 +1,25 @@ +# MullvadVPN WireGuard credentials +# Generate a config at: Mullvad Account → WireGuard configuration → Generate key +# Copy the PrivateKey value and the Address value from the generated config file +WIREGUARD_PRIVATE_KEY= +WIREGUARD_ADDRESSES= + +# Optional: preferred server city/cities (comma-separated, e.g. Gothenburg,Stockholm) +# Leave empty to let gluetun pick automatically +SERVER_CITIES= + +# Linux user/group IDs for file ownership in volumes +# Run `id` on the host to find the right values +PUID= +PGID= + +# Timezone (e.g. Europe/Berlin) +TZ= + +# Host paths for media library and downloads +TV_PATH= +MOVIES_PATH= +DOWNLOADS_PATH= + +# Added by knecht +SEERR_DATA_PATH= diff --git a/services/rrr/docker-compose.yml b/services/rrr/docker-compose.yml new file mode 100644 index 0000000..c586034 --- /dev/null +++ b/services/rrr/docker-compose.yml @@ -0,0 +1,152 @@ +services: + gluetun: + image: qmcgaw/gluetun:latest + container_name: gluetun + restart: unless-stopped + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + - VPN_SERVICE_PROVIDER=mullvad + - VPN_TYPE=wireguard + - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY} + - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES} + - SERVER_CITIES=${SERVER_CITIES} + ports: + - "8080:8080" + volumes: + - gluetun_data:/gluetun + networks: + - proxy + healthcheck: + test: ["CMD", "/gluetun-entrypoint", "healthcheck"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + labels: + - "traefik.enable=true" + # Sonarr + - "traefik.http.routers.sonarr.rule=Host(`sonarr.home.jens.pub`)" + - "traefik.http.routers.sonarr.entrypoints=websecure" + - "traefik.http.routers.sonarr.service=sonarr" + - "traefik.http.services.sonarr.loadbalancer.server.port=8989" + # Radarr + - "traefik.http.routers.radarr.rule=Host(`radarr.home.jens.pub`)" + - "traefik.http.routers.radarr.entrypoints=websecure" + - "traefik.http.routers.radarr.service=radarr" + - "traefik.http.services.radarr.loadbalancer.server.port=7878" + # Prowlarr + - "traefik.http.routers.prowlarr.rule=Host(`prowlarr.home.jens.pub`)" + - "traefik.http.routers.prowlarr.entrypoints=websecure" + - "traefik.http.routers.prowlarr.service=prowlarr" + - "traefik.http.services.prowlarr.loadbalancer.server.port=9696" + # SABnzbd + - "traefik.http.routers.sabnzbd.rule=Host(`sabnzbd.home.jens.pub`)" + - "traefik.http.routers.sabnzbd.entrypoints=websecure" + - "traefik.http.routers.sabnzbd.service=sabnzbd" + - "traefik.http.services.sabnzbd.loadbalancer.server.port=8080" + + sonarr: + image: lscr.io/linuxserver/sonarr:latest + container_name: sonarr + network_mode: "service:gluetun" + restart: unless-stopped + environment: + - PUID=${PUID} + - PGID=${PGID} + - TZ=${TZ} + volumes: + - sonarr_config:/config + - ${TV_PATH}:/tv + - ${DOWNLOADS_PATH}:/downloads + depends_on: + gluetun: + condition: service_healthy + + radarr: + image: lscr.io/linuxserver/radarr:latest + container_name: radarr + network_mode: "service:gluetun" + restart: unless-stopped + environment: + - PUID=${PUID} + - PGID=${PGID} + - TZ=${TZ} + volumes: + - radarr_config:/config + - ${MOVIES_PATH}:/movies + - ${DOWNLOADS_PATH}:/downloads + depends_on: + gluetun: + condition: service_healthy + + prowlarr: + image: lscr.io/linuxserver/prowlarr:latest + container_name: prowlarr + network_mode: "service:gluetun" + restart: unless-stopped + environment: + - PUID=${PUID} + - PGID=${PGID} + - TZ=${TZ} + volumes: + - prowlarr_config:/config + depends_on: + gluetun: + condition: service_healthy + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:latest + container_name: sabnzbd + network_mode: "service:gluetun" + restart: unless-stopped + environment: + - PUID=${PUID} + - PGID=${PGID} + - TZ=${TZ} + - HOST_WHITELIST_ENTRIES=sabnzbd.home.jens.pub + volumes: + - sabnzbd_config:/config + - ${DOWNLOADS_PATH}:/downloads + depends_on: + gluetun: + condition: service_healthy + + seerr: + image: ghcr.io/seerr-team/seerr:latest + container_name: seerr + init: true + restart: unless-stopped + environment: + - TZ=${TZ} + volumes: + - ${SEERR_DATA_PATH}:/app/config + networks: + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.seerr.rule=Host(`seerr.home.jens.pub`)" + - "traefik.http.routers.seerr.entrypoints=websecure" + - "traefik.http.services.seerr.loadbalancer.server.port=5055" + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1 + start_period: 20s + timeout: 3s + interval: 15s + retries: 3 + depends_on: + gluetun: + condition: service_healthy + +networks: + proxy: + external: true + +volumes: + gluetun_data: + sonarr_config: + radarr_config: + prowlarr_config: + sabnzbd_config: