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 list` | Print all stacks with status and drift summary |
|
||||||
| `knecht deploy <stack>` | Deploy a new stack from `services/<stack>/docker-compose.yml` |
|
| `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 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 diff <stack>` | Print compose and env key drift |
|
||||||
| `knecht logs [container]` | Open Dozzle in the browser; deep-links to a specific container if given |
|
| `knecht logs [container]` | Open Dozzle in the browser; deep-links to a specific container if given |
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
## Functional
|
## Functional
|
||||||
|
|
||||||
- [x] `knecht update` — replace `fmt.Scanln` env var prompt with a proper TUI input form for missing keys
|
- [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)
|
- [ ] Compose diff — switch from set-based comparison to a real unified diff (reordered lines currently show as false drift)
|
||||||
|
|
||||||
## TUI
|
## TUI
|
||||||
|
|
||||||
- [ ] Diff view — add scrolling for long diffs (currently clipped)
|
- [ ] 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
|
- [ ] Confirmation prompt before `[u]` update and `[D]` deploy
|
||||||
|
|
||||||
## Housekeeping
|
## 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"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var pruneEnvFlag bool
|
||||||
|
|
||||||
var updateCmd = &cobra.Command{
|
var updateCmd = &cobra.Command{
|
||||||
Use: "update <stack>",
|
Use: "update <stack>",
|
||||||
Short: "Update an existing stack, preserving env vars",
|
Short: "Update an existing stack, preserving env vars",
|
||||||
@@ -43,7 +45,7 @@ var updateCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
env, err := mergeEnvVars(name, remote.Env, exampleKeys)
|
env, err := mergeEnvVars(name, remote.Env, exampleKeys, pruneEnvFlag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -64,12 +66,26 @@ var updateCmd = &cobra.Command{
|
|||||||
// mergeEnvVars preserves existing Portainer env vars and prompts via TUI for
|
// mergeEnvVars preserves existing Portainer env vars and prompts via TUI for
|
||||||
// any keys present in .env.example but missing from Portainer.
|
// any keys present in .env.example but missing from Portainer.
|
||||||
// Returns nil if the user cancelled the form.
|
// 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))
|
envMap := make(map[string]string, len(existing))
|
||||||
for _, e := range existing {
|
for _, e := range existing {
|
||||||
envMap[e.Name] = e.Value
|
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
|
var missing []string
|
||||||
for _, key := range exampleKeys {
|
for _, key := range exampleKeys {
|
||||||
if _, ok := envMap[key]; !ok {
|
if _, ok := envMap[key]; !ok {
|
||||||
@@ -98,5 +114,6 @@ func mergeEnvVars(stackName string, existing []portainer.EnvVar, exampleKeys []s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
updateCmd.Flags().BoolVar(&pruneEnvFlag, "prune-env", false, "remove env vars not defined in .env.example")
|
||||||
rootCmd.AddCommand(updateCmd)
|
rootCmd.AddCommand(updateCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,24 @@ func (c *Client) CreateStack(name, composeContent string, env []EnvVar) (*Stack,
|
|||||||
return &s, nil
|
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) {
|
func (c *Client) UpdateStack(id int, composeContent string, env []EnvVar) (*Stack, error) {
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"stackFileContent": composeContent,
|
"stackFileContent": composeContent,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
@@ -37,6 +39,7 @@ const (
|
|||||||
type stacksLoadedMsg []stackItem
|
type stacksLoadedMsg []stackItem
|
||||||
type updateDoneMsg struct{ err error }
|
type updateDoneMsg struct{ err error }
|
||||||
type deployDoneMsg struct{ err error }
|
type deployDoneMsg struct{ err error }
|
||||||
|
type pruneDoneMsg struct{ err error }
|
||||||
type envExampleWrittenMsg struct{ err error }
|
type envExampleWrittenMsg struct{ err error }
|
||||||
type errMsg error
|
type errMsg error
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ type model struct {
|
|||||||
client *portainer.Client
|
client *portainer.Client
|
||||||
svcPath string
|
svcPath string
|
||||||
ignore map[string]bool
|
ignore map[string]bool
|
||||||
|
logsURL string // base URL for Dozzle
|
||||||
mode viewMode
|
mode viewMode
|
||||||
updating bool
|
updating bool
|
||||||
status string
|
status string
|
||||||
@@ -100,7 +104,7 @@ type model struct {
|
|||||||
height int
|
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 := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||||
l.Title = "knecht"
|
l.Title = "knecht"
|
||||||
l.Styles.Title = titleStyle
|
l.Styles.Title = titleStyle
|
||||||
@@ -115,7 +119,7 @@ func initialModel(client *portainer.Client, svcPath string, ignore []string) mod
|
|||||||
ignoreSet[name] = true
|
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 ──────────────────────────────────────────────────────────────────
|
// ── 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 {
|
func writeEnvExample(s stackItem) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if s.local == nil || s.driftResult == nil {
|
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:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.list.SetSize(msg.Width/2, msg.Height-4)
|
m.list.SetSize(msg.Width/2, msg.Height-2)
|
||||||
|
|
||||||
case stacksLoadedMsg:
|
case stacksLoadedMsg:
|
||||||
m.updating = false
|
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))
|
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:
|
case envExampleWrittenMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = errorStyle.Render("Failed to write .env.example: " + msg.err.Error())
|
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)
|
cmds = append(cmds, deployStack(m.client, item), m.spinner.Tick)
|
||||||
return m, tea.Batch(cmds...)
|
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":
|
case "e":
|
||||||
item, ok := m.list.SelectedItem().(stackItem)
|
item, ok := m.list.SelectedItem().(stackItem)
|
||||||
if !ok || item.driftResult == nil {
|
if !ok || item.driftResult == nil {
|
||||||
@@ -364,11 +452,15 @@ func (m model) View() string {
|
|||||||
detail += "\n\n" + m.status
|
detail += "\n\n" + m.status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const marginLeft = 4
|
||||||
|
const borderWidth = 2
|
||||||
|
rightWidth := m.width - lipgloss.Width(left) - marginLeft - borderWidth
|
||||||
|
|
||||||
right := lipgloss.NewStyle().
|
right := lipgloss.NewStyle().
|
||||||
Width(m.width/2 - 2).
|
Width(rightWidth).
|
||||||
Height(m.height - 4).
|
Height(m.height - 4).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
MarginLeft(4).
|
MarginLeft(marginLeft).
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
Render(detail)
|
Render(detail)
|
||||||
|
|
||||||
@@ -409,7 +501,7 @@ func renderSummary(s stackItem) string {
|
|||||||
for _, k := range s.driftResult.UnknownKeys {
|
for _, k := range s.driftResult.UnknownKeys {
|
||||||
out += fmt.Sprintf(" ? %s\n", k)
|
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 {
|
if len(s.driftResult.PortainerOnlyKeys) > 0 {
|
||||||
out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n"
|
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 += 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
|
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 {
|
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()
|
_, err := p.Run()
|
||||||
return err
|
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