more knecht
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
45
knecht/cmd/restart.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user