add knecht

This commit is contained in:
2026-04-04 14:34:22 +02:00
parent 736477cf57
commit fe6ce5249e
22 changed files with 1885 additions and 1 deletions

68
knecht/cmd/deploy.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
}