add knecht
This commit is contained in:
42
CLAUDE.md
Normal file
42
CLAUDE.md
Normal file
@@ -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/<name>/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/<name>/.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/<name>/docker-compose.yml` — attach to the `proxy` network, add Traefik labels for routing/TLS
|
||||||
|
2. If secrets are needed, add `services/<name>/.env.example`
|
||||||
|
3. Add a `docs/<name>.md` with purpose, image, ports, volumes, and config details
|
||||||
|
4. Update `docs/index.md` to include the new service
|
||||||
@@ -86,6 +86,8 @@ The `proxy` network is an **external** bridge network created manually. All serv
|
|||||||
| `watchtower` | Automatic image updates | [watchtower.md](watchtower.md) |
|
| `watchtower` | Automatic image updates | [watchtower.md](watchtower.md) |
|
||||||
| `beszel` | Container & host metrics | [beszel.md](beszel.md) |
|
| `beszel` | Container & host metrics | [beszel.md](beszel.md) |
|
||||||
| `dozzle` | Container log viewer | [dozzle.md](dozzle.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
|
├── portainer.home.jens.pub ──→ portainer:9000
|
||||||
├── vault.home.jens.pub ──→ vaultwarden:80 (password manager)
|
├── vault.home.jens.pub ──→ vaultwarden:80 (password manager)
|
||||||
├── beszel.home.jens.pub ──→ beszel:8090 (metrics)
|
├── 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)
|
[adguard/adguardhome] ──── DNS :53 (TCP/UDP)
|
||||||
[portainer/portainer-ee] ──── Portainer UI :9443
|
[portainer/portainer-ee] ──── Portainer UI :9443
|
||||||
|
|||||||
42
docs/jellyfin.md
Normal file
42
docs/jellyfin.md
Normal file
@@ -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 | <https://jellyfin.home.jens.pub> |
|
||||||
|
| 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.
|
||||||
84
docs/rrr.md
Normal file
84
docs/rrr.md
Normal file
@@ -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` | <https://sonarr.home.jens.pub> |
|
||||||
|
| Radarr | `lscr.io/linuxserver/radarr:latest` | <https://radarr.home.jens.pub> |
|
||||||
|
| Prowlarr | `lscr.io/linuxserver/prowlarr:latest> | <https://prowlarr.home.jens.pub> |
|
||||||
|
| SABnzbd | `lscr.io/linuxserver/sabnzbd:latest` | <https://sabnzbd.home.jens.pub> |
|
||||||
|
| 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:<port>`. 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
|
||||||
68
knecht/cmd/deploy.go
Normal file
68
knecht/cmd/deploy.go
Normal file
@@ -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 <stack>",
|
||||||
|
Short: "Deploy a new stack from services/<stack>/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)
|
||||||
|
}
|
||||||
84
knecht/cmd/diff.go
Normal file
84
knecht/cmd/diff.go
Normal file
@@ -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 <stack>",
|
||||||
|
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)
|
||||||
|
}
|
||||||
103
knecht/cmd/list.go
Normal file
103
knecht/cmd/list.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
68
knecht/cmd/logs.go
Normal file
68
knecht/cmd/logs.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
61
knecht/cmd/root.go
Normal file
61
knecht/cmd/root.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
85
knecht/cmd/update.go
Normal file
85
knecht/cmd/update.go
Normal file
@@ -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 <stack>",
|
||||||
|
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)
|
||||||
|
}
|
||||||
56
knecht/config/config.go
Normal file
56
knecht/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
102
knecht/drift/drift.go
Normal file
102
knecht/drift/drift.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
35
knecht/go.mod
Normal file
35
knecht/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
64
knecht/go.sum
Normal file
64
knecht/go.sum
Normal file
@@ -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=
|
||||||
7
knecht/main.go
Normal file
7
knecht/main.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/jensbecker/homelab/knecht/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
199
knecht/portainer/client.go
Normal file
199
knecht/portainer/client.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
144
knecht/stack/stack.go
Normal file
144
knecht/stack/stack.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
421
knecht/tui/tui.go
Normal file
421
knecht/tui/tui.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
6
services/jellyfin/.env.example
Normal file
6
services/jellyfin/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Generated by knecht from Portainer env vars
|
||||||
|
PUID=
|
||||||
|
PGID=
|
||||||
|
TZ=
|
||||||
|
TV_PATH=
|
||||||
|
MOVIES_PATH=
|
||||||
29
services/jellyfin/docker-compose.yml
Normal file
29
services/jellyfin/docker-compose.yml
Normal file
@@ -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
|
||||||
25
services/rrr/.env.example
Normal file
25
services/rrr/.env.example
Normal file
@@ -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=
|
||||||
152
services/rrr/docker-compose.yml
Normal file
152
services/rrr/docker-compose.yml
Normal file
@@ -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:
|
||||||
Reference in New Issue
Block a user