diff --git a/knecht/README.md b/knecht/README.md index d3dfe9a..c3f50f6 100644 --- a/knecht/README.md +++ b/knecht/README.md @@ -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 ` | Deploy a new stack from `services//docker-compose.yml` | | `knecht update ` | Update an existing stack, preserving Portainer env vars | +| `knecht restart ` | Stop then start a stack | | `knecht diff ` | Print compose and env key drift | | `knecht logs [container]` | Open Dozzle in the browser; deep-links to a specific container if given | diff --git a/knecht/TODO.md b/knecht/TODO.md index 7184340..29c82dd 100644 --- a/knecht/TODO.md +++ b/knecht/TODO.md @@ -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 ` — specced but not implemented +- [x] `knecht restart ` — 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 diff --git a/knecht/cmd/restart.go b/knecht/cmd/restart.go new file mode 100644 index 0000000..8bdf52d --- /dev/null +++ b/knecht/cmd/restart.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var restartCmd = &cobra.Command{ + Use: "restart ", + 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) +} diff --git a/knecht/cmd/update.go b/knecht/cmd/update.go index 0bb5266..6ab1879 100644 --- a/knecht/cmd/update.go +++ b/knecht/cmd/update.go @@ -9,6 +9,8 @@ import ( "github.com/spf13/cobra" ) +var pruneEnvFlag bool + var updateCmd = &cobra.Command{ Use: "update ", 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) } diff --git a/knecht/portainer/client.go b/knecht/portainer/client.go index 0ace4b0..2ae0d24 100644 --- a/knecht/portainer/client.go +++ b/knecht/portainer/client.go @@ -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, diff --git a/knecht/tui/tui.go b/knecht/tui/tui.go index 025edcf..2893238 100644 --- a/knecht/tui/tui.go +++ b/knecht/tui/tui.go @@ -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 +}