more knecht

This commit is contained in:
2026-04-04 15:17:33 +02:00
parent b4fddbb5b6
commit 397cbea7fb
6 changed files with 194 additions and 12 deletions

View File

@@ -35,6 +35,7 @@ The API token is generated in Portainer under **Account → Access tokens**.
| `knecht list` | Print all stacks with status and drift summary |
| `knecht deploy <stack>` | Deploy a new stack from `services/<stack>/docker-compose.yml` |
| `knecht update <stack>` | Update an existing stack, preserving Portainer env vars |
| `knecht restart <stack>` | Stop then start a stack |
| `knecht diff <stack>` | Print compose and env key drift |
| `knecht logs [container]` | Open Dozzle in the browser; deep-links to a specific container if given |

View File

@@ -3,13 +3,13 @@
## Functional
- [x] `knecht update` — replace `fmt.Scanln` env var prompt with a proper TUI input form for missing keys
- [ ] `knecht restart <stack>` — specced but not implemented
- [x] `knecht restart <stack>` — specced but not implemented
- [ ] Compose diff — switch from set-based comparison to a real unified diff (reordered lines currently show as false drift)
## TUI
- [ ] Diff view — add scrolling for long diffs (currently clipped)
- [ ] Add `[l]` keybind to open Dozzle logs for the selected stack
- [x] Add `[l]` keybind to open Dozzle logs for the selected stack
- [ ] Confirmation prompt before `[u]` update and `[D]` deploy
## Housekeeping

45
knecht/cmd/restart.go Normal file
View File

@@ -0,0 +1,45 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var restartCmd = &cobra.Command{
Use: "restart <stack>",
Short: "Stop and start a stack",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
_, client, _, err := setup()
if err != nil {
return err
}
name := args[0]
remote, err := client.GetStackByName(name)
if err != nil {
return err
}
if remote == nil {
return fmt.Errorf("stack %q not found", name)
}
fmt.Printf("Stopping %q...\n", name)
if err := client.StopStack(remote.ID); err != nil {
return fmt.Errorf("stop failed: %w", err)
}
fmt.Printf("Starting %q...\n", name)
if err := client.StartStack(remote.ID); err != nil {
return fmt.Errorf("start failed: %w", err)
}
fmt.Printf("Restarted %q\n", name)
return nil
},
}
func init() {
rootCmd.AddCommand(restartCmd)
}

View File

@@ -9,6 +9,8 @@ import (
"github.com/spf13/cobra"
)
var pruneEnvFlag bool
var updateCmd = &cobra.Command{
Use: "update <stack>",
Short: "Update an existing stack, preserving env vars",
@@ -43,7 +45,7 @@ var updateCmd = &cobra.Command{
return err
}
env, err := mergeEnvVars(name, remote.Env, exampleKeys)
env, err := mergeEnvVars(name, remote.Env, exampleKeys, pruneEnvFlag)
if err != nil {
return err
}
@@ -64,12 +66,26 @@ var updateCmd = &cobra.Command{
// mergeEnvVars preserves existing Portainer env vars and prompts via TUI for
// any keys present in .env.example but missing from Portainer.
// Returns nil if the user cancelled the form.
func mergeEnvVars(stackName string, existing []portainer.EnvVar, exampleKeys []string) ([]portainer.EnvVar, error) {
func mergeEnvVars(stackName string, existing []portainer.EnvVar, exampleKeys []string, prune bool) ([]portainer.EnvVar, error) {
envMap := make(map[string]string, len(existing))
for _, e := range existing {
envMap[e.Name] = e.Value
}
// Remove keys not in .env.example when pruning
if prune && exampleKeys != nil {
allowed := make(map[string]bool, len(exampleKeys))
for _, k := range exampleKeys {
allowed[k] = true
}
for k := range envMap {
if !allowed[k] {
fmt.Printf("Removing unknown env var %q\n", k)
delete(envMap, k)
}
}
}
var missing []string
for _, key := range exampleKeys {
if _, ok := envMap[key]; !ok {
@@ -98,5 +114,6 @@ func mergeEnvVars(stackName string, existing []portainer.EnvVar, exampleKeys []s
}
func init() {
updateCmd.Flags().BoolVar(&pruneEnvFlag, "prune-env", false, "remove env vars not defined in .env.example")
rootCmd.AddCommand(updateCmd)
}

View File

@@ -147,6 +147,24 @@ func (c *Client) CreateStack(name, composeContent string, env []EnvVar) (*Stack,
return &s, nil
}
func (c *Client) StopStack(id int) error {
resp, err := c.do("POST", fmt.Sprintf("/api/stacks/%d/stop?endpointId=%d", id, c.endpointID), nil)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func (c *Client) StartStack(id int) error {
resp, err := c.do("POST", fmt.Sprintf("/api/stacks/%d/start?endpointId=%d", id, c.endpointID), nil)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func (c *Client) UpdateStack(id int, composeContent string, env []EnvVar) (*Stack, error) {
body := map[string]any{
"stackFileContent": composeContent,

View File

@@ -2,6 +2,8 @@ package tui
import (
"fmt"
"os/exec"
"runtime"
"strings"
"github.com/charmbracelet/bubbles/list"
@@ -37,6 +39,7 @@ const (
type stacksLoadedMsg []stackItem
type updateDoneMsg struct{ err error }
type deployDoneMsg struct{ err error }
type pruneDoneMsg struct{ err error }
type envExampleWrittenMsg struct{ err error }
type errMsg error
@@ -93,6 +96,7 @@ type model struct {
client *portainer.Client
svcPath string
ignore map[string]bool
logsURL string // base URL for Dozzle
mode viewMode
updating bool
status string
@@ -100,7 +104,7 @@ type model struct {
height int
}
func initialModel(client *portainer.Client, svcPath string, ignore []string) model {
func initialModel(client *portainer.Client, svcPath string, ignore []string, logsURL string) model {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "knecht"
l.Styles.Title = titleStyle
@@ -115,7 +119,7 @@ func initialModel(client *portainer.Client, svcPath string, ignore []string) mod
ignoreSet[name] = true
}
return model{list: l, spinner: s, client: client, svcPath: svcPath, ignore: ignoreSet}
return model{list: l, spinner: s, client: client, svcPath: svcPath, ignore: ignoreSet, logsURL: logsURL}
}
// ── commands ──────────────────────────────────────────────────────────────────
@@ -191,6 +195,64 @@ func deployStack(client *portainer.Client, s stackItem) tea.Cmd {
}
}
func openLogs(client *portainer.Client, s stackItem, baseURL string) tea.Cmd {
return func() tea.Msg {
url := baseURL
// Try to deep-link to a container with the same name as the stack
id, err := client.FindContainer(s.name())
if err == nil && id != "" {
shortID := id
if len(shortID) > 12 {
shortID = shortID[:12]
}
url = baseURL + "/container/" + shortID
}
openBrowser(url)
return nil
}
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
default:
return
}
cmd.Start() //nolint
}
func pruneEnvVars(client *portainer.Client, s stackItem) tea.Cmd {
return func() tea.Msg {
if s.remote == nil || s.local == nil || s.driftResult == nil {
return pruneDoneMsg{fmt.Errorf("no stack info")}
}
exampleKeys, err := s.local.EnvExampleKeys()
if err != nil || exampleKeys == nil {
return pruneDoneMsg{fmt.Errorf("no .env.example to prune against")}
}
allowed := make(map[string]bool, len(exampleKeys))
for _, k := range exampleKeys {
allowed[k] = true
}
filtered := make([]portainer.EnvVar, 0, len(s.remote.Env))
for _, e := range s.remote.Env {
if allowed[e.Name] {
filtered = append(filtered, e)
}
}
compose, err := s.local.ReadCompose()
if err != nil {
return pruneDoneMsg{err}
}
_, err = client.UpdateStack(s.remote.ID, compose, filtered)
return pruneDoneMsg{err}
}
}
func writeEnvExample(s stackItem) tea.Cmd {
return func() tea.Msg {
if s.local == nil || s.driftResult == nil {
@@ -221,7 +283,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetSize(msg.Width/2, msg.Height-4)
m.list.SetSize(msg.Width/2, msg.Height-2)
case stacksLoadedMsg:
m.updating = false
@@ -250,6 +312,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore))
}
case pruneDoneMsg:
m.updating = false
if msg.err != nil {
m.status = errorStyle.Render("Prune failed: " + msg.err.Error())
} else {
m.status = successStyle.Render("Unknown env keys removed — refreshing...")
cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore))
}
case envExampleWrittenMsg:
if msg.err != nil {
m.status = errorStyle.Render("Failed to write .env.example: " + msg.err.Error())
@@ -317,6 +388,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, deployStack(m.client, item), m.spinner.Tick)
return m, tea.Batch(cmds...)
case "l":
item, ok := m.list.SelectedItem().(stackItem)
if !ok {
return m, nil
}
return m, openLogs(m.client, item, m.logsURL)
case "c":
item, ok := m.list.SelectedItem().(stackItem)
if !ok || item.driftResult == nil || len(item.driftResult.UnknownKeys) == 0 || m.updating {
return m, nil
}
m.updating = true
m.status = ""
cmds = append(cmds, pruneEnvVars(m.client, item), m.spinner.Tick)
return m, tea.Batch(cmds...)
case "e":
item, ok := m.list.SelectedItem().(stackItem)
if !ok || item.driftResult == nil {
@@ -364,11 +452,15 @@ func (m model) View() string {
detail += "\n\n" + m.status
}
const marginLeft = 4
const borderWidth = 2
rightWidth := m.width - lipgloss.Width(left) - marginLeft - borderWidth
right := lipgloss.NewStyle().
Width(m.width/2 - 2).
Width(rightWidth).
Height(m.height - 4).
Padding(1, 2).
MarginLeft(4).
MarginLeft(marginLeft).
Border(lipgloss.RoundedBorder()).
Render(detail)
@@ -409,7 +501,7 @@ func renderSummary(s stackItem) string {
for _, k := range s.driftResult.UnknownKeys {
out += fmt.Sprintf(" ? %s\n", k)
}
out += mutedStyle.Render(" [e] append to .env.example") + "\n"
out += mutedStyle.Render(" [e] append to .env.example [c] remove from Portainer") + "\n"
}
if len(s.driftResult.PortainerOnlyKeys) > 0 {
out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n"
@@ -419,7 +511,7 @@ func renderSummary(s stackItem) string {
out += mutedStyle.Render(" [e] generate .env.example") + "\n"
}
out += "\n" + mutedStyle.Render("[u] update [d] diff [r] refresh [esc] back [q] quit")
out += "\n" + mutedStyle.Render("[u] update [d] diff [l] logs [r] refresh [esc] back [q] quit")
return out
}
@@ -493,7 +585,16 @@ func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Loc
}
func Run(client *portainer.Client, svcPath string, cfg *config.Config) error {
p := tea.NewProgram(initialModel(client, svcPath, cfg.Ignore), tea.WithAltScreen())
logsURL := deriveLogsURL(cfg.URL)
p := tea.NewProgram(initialModel(client, svcPath, cfg.Ignore, logsURL), tea.WithAltScreen())
_, err := p.Run()
return err
}
func deriveLogsURL(portainerURL string) string {
domain := strings.TrimPrefix(strings.TrimPrefix(portainerURL, "https://"), "http://")
if idx := strings.Index(domain, "."); idx != -1 {
return "https://logs." + domain[idx+1:]
}
return "https://logs." + domain
}