add knecht
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user