From dbf3fa46fb1e79d11483a7eb132ac8c658fa5d17 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:41:11 -0300 Subject: [PATCH 01/25] chore: update .gitignore to exclude Chati.dev runtime files --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index b5109ce..f39b4c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,11 @@ bin/ data/ + +# Chati.dev runtime files (session lock — not committed) +.chati/memories/*/session/ +CLAUDE.local.md +CLAUDE.md +chati.dev/ +.vscode/ +.claude/ +.chati/ \ No newline at end of file From b5792d262168d15cdb286d4ef5845ed69e9eeb3e Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:41:46 -0300 Subject: [PATCH 02/25] feat: migrate local metrics to gopsutil v4 --- go.mod | 17 ++++++++-- go.sum | 31 ++++++++++++++++- internal/metrics/cpu.go | 68 +++---------------------------------- internal/metrics/disk.go | 28 +++++---------- internal/metrics/loadavg.go | 32 +++++------------ internal/metrics/memory.go | 57 +++++-------------------------- internal/metrics/uptime.go | 25 +++----------- internal/model/metrics.go | 10 +++--- 8 files changed, 84 insertions(+), 184 deletions(-) diff --git a/go.mod b/go.mod index 6a603ea..66ee67d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,18 @@ module github.com/getkaze/keel go 1.24.1 require ( - github.com/creack/pty v1.1.24 // indirect - github.com/gorilla/websocket v1.5.3 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/creack/pty v1.1.24 + github.com/gorilla/websocket v1.5.3 + github.com/shirou/gopsutil/v4 v4.26.2 +) + +require ( + github.com/ebitengine/purego v0.10.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index fe6138c..c3b539c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,36 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/metrics/cpu.go b/internal/metrics/cpu.go index 3de28ca..f8154fb 100644 --- a/internal/metrics/cpu.go +++ b/internal/metrics/cpu.go @@ -1,78 +1,20 @@ package metrics import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" "time" "github.com/getkaze/keel/internal/model" + "github.com/shirou/gopsutil/v4/cpu" ) -// ReadCPU reads CPU usage from /proc/stat with two samples 1 second apart. +// ReadCPU reads CPU usage with two samples 1 second apart via gopsutil. func ReadCPU() (model.CPUMetrics, error) { - idle1, total1, err := readCPUSample() + percents, err := cpu.Percent(1*time.Second, false) if err != nil { return model.CPUMetrics{}, err } - - time.Sleep(1 * time.Second) - - idle2, total2, err := readCPUSample() - if err != nil { - return model.CPUMetrics{}, err - } - - idleDelta := float64(idle2 - idle1) - totalDelta := float64(total2 - total1) - - if totalDelta == 0 { + if len(percents) == 0 { return model.CPUMetrics{UsagePercent: 0}, nil } - - usage := (1.0 - idleDelta/totalDelta) * 100.0 - return model.CPUMetrics{UsagePercent: usage}, nil -} - -func readCPUSample() (idle, total uint64, err error) { - f, err := os.Open("/proc/stat") - if err != nil { - return 0, 0, fmt.Errorf("open /proc/stat: %w", err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if !strings.HasPrefix(line, "cpu ") { - continue - } - - fields := strings.Fields(line) - if len(fields) < 5 { - return 0, 0, fmt.Errorf("unexpected /proc/stat format") - } - - var values []uint64 - for _, field := range fields[1:] { - v, err := strconv.ParseUint(field, 10, 64) - if err != nil { - return 0, 0, fmt.Errorf("parse /proc/stat: %w", err) - } - values = append(values, v) - } - - for _, v := range values { - total += v - } - // idle is the 4th field (index 3) - if len(values) > 3 { - idle = values[3] - } - return idle, total, nil - } - - return 0, 0, fmt.Errorf("cpu line not found in /proc/stat") + return model.CPUMetrics{UsagePercent: percents[0]}, nil } diff --git a/internal/metrics/disk.go b/internal/metrics/disk.go index dbbe1ba..38ca696 100644 --- a/internal/metrics/disk.go +++ b/internal/metrics/disk.go @@ -1,32 +1,20 @@ package metrics import ( - "syscall" - "github.com/getkaze/keel/internal/model" + "github.com/shirou/gopsutil/v4/disk" ) -// ReadDisk reads disk usage for the root partition via syscall.Statfs. +// ReadDisk reads disk usage for the root partition via gopsutil. func ReadDisk() (model.DiskMetrics, error) { - var stat syscall.Statfs_t - if err := syscall.Statfs("/", &stat); err != nil { + u, err := disk.Usage("/") + if err != nil { return model.DiskMetrics{}, err } - - total := stat.Blocks * uint64(stat.Bsize) - free := stat.Bfree * uint64(stat.Bsize) - available := stat.Bavail * uint64(stat.Bsize) - used := total - free - - var percent float64 - if total > 0 { - percent = float64(used) / float64(total) * 100.0 - } - return model.DiskMetrics{ - TotalBytes: total, - UsedBytes: used, - AvailableBytes: available, - UsagePercent: percent, + TotalBytes: u.Total, + UsedBytes: u.Used, + AvailableBytes: u.Free, + UsagePercent: u.UsedPercent, }, nil } diff --git a/internal/metrics/loadavg.go b/internal/metrics/loadavg.go index ca5957f..3291c3e 100644 --- a/internal/metrics/loadavg.go +++ b/internal/metrics/loadavg.go @@ -1,33 +1,19 @@ package metrics import ( - "fmt" - "os" - "strconv" - "strings" - "github.com/getkaze/keel/internal/model" + "github.com/shirou/gopsutil/v4/load" ) -// ReadLoadAvg reads load averages from /proc/loadavg. +// ReadLoadAvg reads load averages via gopsutil. func ReadLoadAvg() (model.LoadAvgMetrics, error) { - data, err := os.ReadFile("/proc/loadavg") + avg, err := load.Avg() if err != nil { - return model.LoadAvgMetrics{}, fmt.Errorf("read /proc/loadavg: %w", err) - } - - fields := strings.Fields(string(data)) - if len(fields) < 3 { - return model.LoadAvgMetrics{}, fmt.Errorf("unexpected /proc/loadavg format") + return model.LoadAvgMetrics{}, err } - - var vals [3]float64 - for i := 0; i < 3; i++ { - vals[i], err = strconv.ParseFloat(fields[i], 64) - if err != nil { - return model.LoadAvgMetrics{}, fmt.Errorf("parse /proc/loadavg: %w", err) - } - } - - return model.LoadAvgMetrics{Load1: vals[0], Load5: vals[1], Load15: vals[2]}, nil + return model.LoadAvgMetrics{ + Load1: avg.Load1, + Load5: avg.Load5, + Load15: avg.Load15, + }, nil } diff --git a/internal/metrics/memory.go b/internal/metrics/memory.go index a10907b..463990d 100644 --- a/internal/metrics/memory.go +++ b/internal/metrics/memory.go @@ -1,61 +1,20 @@ package metrics import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" - "github.com/getkaze/keel/internal/model" + "github.com/shirou/gopsutil/v4/mem" ) -// ReadMemory reads RAM usage from /proc/meminfo. +// ReadMemory reads RAM usage via gopsutil. func ReadMemory() (model.MemoryMetrics, error) { - f, err := os.Open("/proc/meminfo") + v, err := mem.VirtualMemory() if err != nil { - return model.MemoryMetrics{}, fmt.Errorf("open /proc/meminfo: %w", err) - } - defer f.Close() - - values := make(map[string]uint64) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - if key != "MemTotal" && key != "MemAvailable" { - continue - } - - valStr := strings.TrimSpace(parts[1]) - valStr = strings.TrimSuffix(valStr, " kB") - valStr = strings.TrimSpace(valStr) - - v, err := strconv.ParseUint(valStr, 10, 64) - if err != nil { - continue - } - values[key] = v * 1024 // convert kB to bytes + return model.MemoryMetrics{}, err } - - total := values["MemTotal"] - available := values["MemAvailable"] - used := total - available - - var percent float64 - if total > 0 { - percent = float64(used) / float64(total) * 100.0 - } - return model.MemoryMetrics{ - TotalBytes: total, - UsedBytes: used, - AvailableBytes: available, - UsagePercent: percent, + TotalBytes: v.Total, + UsedBytes: v.Used, + AvailableBytes: v.Available, + UsagePercent: v.UsedPercent, }, nil } diff --git a/internal/metrics/uptime.go b/internal/metrics/uptime.go index ad6000f..52d4cde 100644 --- a/internal/metrics/uptime.go +++ b/internal/metrics/uptime.go @@ -1,30 +1,15 @@ package metrics import ( - "fmt" - "os" - "strconv" - "strings" - "github.com/getkaze/keel/internal/model" + "github.com/shirou/gopsutil/v4/host" ) -// ReadUptime reads host uptime from /proc/uptime. +// ReadUptime reads host uptime via gopsutil. func ReadUptime() (model.UptimeMetrics, error) { - data, err := os.ReadFile("/proc/uptime") + secs, err := host.Uptime() if err != nil { - return model.UptimeMetrics{}, fmt.Errorf("read /proc/uptime: %w", err) - } - - fields := strings.Fields(string(data)) - if len(fields) < 1 { - return model.UptimeMetrics{}, fmt.Errorf("unexpected /proc/uptime format") + return model.UptimeMetrics{}, err } - - secs, err := strconv.ParseFloat(fields[0], 64) - if err != nil { - return model.UptimeMetrics{}, fmt.Errorf("parse /proc/uptime: %w", err) - } - - return model.UptimeMetrics{UptimeSeconds: secs}, nil + return model.UptimeMetrics{UptimeSeconds: float64(secs)}, nil } diff --git a/internal/model/metrics.go b/internal/model/metrics.go index 7ebca69..076a43f 100644 --- a/internal/model/metrics.go +++ b/internal/model/metrics.go @@ -10,12 +10,12 @@ type SystemMetrics struct { Containers []ContainerStats `json:"containers"` } -// CPUMetrics holds CPU usage information from /proc/stat. +// CPUMetrics holds CPU usage information. type CPUMetrics struct { UsagePercent float64 `json:"usage_percent"` } -// MemoryMetrics holds RAM usage information from /proc/meminfo. +// MemoryMetrics holds RAM usage information. type MemoryMetrics struct { TotalBytes uint64 `json:"total_bytes"` UsedBytes uint64 `json:"used_bytes"` @@ -23,7 +23,7 @@ type MemoryMetrics struct { UsagePercent float64 `json:"usage_percent"` } -// DiskMetrics holds disk usage information from syscall.Statfs. +// DiskMetrics holds disk usage information. type DiskMetrics struct { TotalBytes uint64 `json:"total_bytes"` UsedBytes uint64 `json:"used_bytes"` @@ -31,14 +31,14 @@ type DiskMetrics struct { UsagePercent float64 `json:"usage_percent"` } -// LoadAvgMetrics holds load average from /proc/loadavg. +// LoadAvgMetrics holds load average. type LoadAvgMetrics struct { Load1 float64 `json:"load1"` Load5 float64 `json:"load5"` Load15 float64 `json:"load15"` } -// UptimeMetrics holds host uptime from /proc/uptime. +// UptimeMetrics holds host uptime. type UptimeMetrics struct { UptimeSeconds float64 `json:"uptime_seconds"` } From 32f940391f91e620abf39017a413f57e4a329d63 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:42:34 -0300 Subject: [PATCH 03/25] feat: extract SSH utilities into internal/ssh package --- internal/ssh/ssh.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 internal/ssh/ssh.go diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..728725e --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,43 @@ +package ssh + +import ( + "os" + "path/filepath" + "strings" + + "github.com/getkaze/keel/internal/config" +) + +// ExpandHome expands a leading ~/ to the user's home directory. +func ExpandHome(path string) string { + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + +// BuildArgs returns SSH connection flags for the given target, including +// key, jump-host proxy, and user@host. The caller appends the remote +// command or additional flags (e.g. -nNT for tunnels). +func BuildArgs(t *config.TargetConfig) []string { + args := []string{ + "-o", "StrictHostKeyChecking=accept-new", + "-o", "BatchMode=yes", + "-o", "LogLevel=ERROR", + } + if t.SSHKey != "" { + args = append(args, "-i", ExpandHome(t.SSHKey)) + } + if t.SSHJump != "" { + proxyCmd := "ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o LogLevel=ERROR" + if t.SSHKey != "" { + proxyCmd += " -i " + ExpandHome(t.SSHKey) + } + proxyCmd += " -W %h:%p " + t.SSHJump + args = append(args, "-o", "ProxyCommand="+proxyCmd) + } + args = append(args, t.SSHUser+"@"+t.Host) + return args +} From 58f00ca1ddd69e42bd27f5488d4d21497a29e7ee Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:42:57 -0300 Subject: [PATCH 04/25] feat: introduce CmdRunner abstraction and LocalRunner --- internal/docker/cmdrunner.go | 70 +++++++++++++++++++++++++++++++++ internal/docker/local_runner.go | 62 +++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 internal/docker/cmdrunner.go create mode 100644 internal/docker/local_runner.go diff --git a/internal/docker/cmdrunner.go b/internal/docker/cmdrunner.go new file mode 100644 index 0000000..cc3ca64 --- /dev/null +++ b/internal/docker/cmdrunner.go @@ -0,0 +1,70 @@ +package docker + +import ( + "context" + "os/exec" + "sync" + + "github.com/getkaze/keel/internal/model" +) + +// CmdRunner abstracts Docker CLI execution so the Executor works +// transparently with both local and remote (SSH) targets. +type CmdRunner interface { + // DockerCmd returns an *exec.Cmd that will execute "docker " + // on the appropriate target (local or remote via SSH). + DockerCmd(ctx context.Context, args ...string) *exec.Cmd + + // SyncFiles copies service file mounts to the target host. + // For local targets this is a no-op; for remote targets it uses scp. + SyncFiles(ctx context.Context, svc model.Service, keelDir string) error + + // GHCRLogin authenticates with GitHub Container Registry on the target. + GHCRLogin(ctx context.Context, keelDir string) error + + // PortBind returns the address to bind container ports to (e.g. "127.0.0.1"). + PortBind() string +} + +// ReloadableRunner wraps a CmdRunner and allows swapping the underlying +// implementation at runtime (e.g., when the target config changes). +type ReloadableRunner struct { + mu sync.RWMutex + inner CmdRunner +} + +// NewReloadableRunner creates a ReloadableRunner with the given initial runner. +func NewReloadableRunner(r CmdRunner) *ReloadableRunner { + return &ReloadableRunner{inner: r} +} + +func (r *ReloadableRunner) DockerCmd(ctx context.Context, args ...string) *exec.Cmd { + r.mu.RLock() + defer r.mu.RUnlock() + return r.inner.DockerCmd(ctx, args...) +} + +func (r *ReloadableRunner) SyncFiles(ctx context.Context, svc model.Service, keelDir string) error { + r.mu.RLock() + defer r.mu.RUnlock() + return r.inner.SyncFiles(ctx, svc, keelDir) +} + +func (r *ReloadableRunner) GHCRLogin(ctx context.Context, keelDir string) error { + r.mu.RLock() + defer r.mu.RUnlock() + return r.inner.GHCRLogin(ctx, keelDir) +} + +func (r *ReloadableRunner) PortBind() string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.inner.PortBind() +} + +// Swap replaces the underlying CmdRunner. Safe for concurrent use. +func (r *ReloadableRunner) Swap(newRunner CmdRunner) { + r.mu.Lock() + defer r.mu.Unlock() + r.inner = newRunner +} diff --git a/internal/docker/local_runner.go b/internal/docker/local_runner.go new file mode 100644 index 0000000..de46862 --- /dev/null +++ b/internal/docker/local_runner.go @@ -0,0 +1,62 @@ +package docker + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/getkaze/keel/internal/model" +) + +// LocalRunner implements CmdRunner for local Docker targets. +type LocalRunner struct{} + +// NewLocalRunner creates a runner that executes Docker commands locally. +func NewLocalRunner() *LocalRunner { + return &LocalRunner{} +} + +// DockerCmd returns an exec.Cmd that runs "docker " locally. +func (r *LocalRunner) DockerCmd(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "docker", args...) +} + +// SyncFiles is a no-op for local targets — files are already on the host. +func (r *LocalRunner) SyncFiles(_ context.Context, _ model.Service, _ string) error { + return nil +} + +// GHCRLogin authenticates with GHCR using credentials from the data directory. +func (r *LocalRunner) GHCRLogin(ctx context.Context, keelDir string) error { + userPath := keelDir + "/state/ghcr-user" + patPath := keelDir + "/state/ghcr-pat" + + user, err := os.ReadFile(userPath) + if err != nil { + return nil // no credentials configured + } + pat, err := os.ReadFile(patPath) + if err != nil { + return nil + } + + ghcrUser := strings.TrimSpace(string(user)) + ghcrPat := strings.TrimSpace(string(pat)) + if ghcrUser == "" || ghcrPat == "" { + return nil + } + + cmd := exec.CommandContext(ctx, "docker", "login", "ghcr.io", "-u", ghcrUser, "--password-stdin") + cmd.Stdin = strings.NewReader(ghcrPat) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ghcr login: %s: %w", string(out), err) + } + return nil +} + +// PortBind returns "127.0.0.1" for local targets. +func (r *LocalRunner) PortBind() string { + return "127.0.0.1" +} From a3eb94305984c32e9dc229af4756e8126042084f Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:43:33 -0300 Subject: [PATCH 05/25] refactor: wire CmdRunner through Executor and Runner --- internal/cli/hosts.go | 4 + internal/cli/runner.go | 89 +++++++++---------- internal/docker/executor.go | 170 +++++++++++++++++++++--------------- internal/server/routes.go | 17 ++-- internal/server/server.go | 4 + main.go | 143 +++++++++++++++--------------- 6 files changed, 232 insertions(+), 195 deletions(-) diff --git a/internal/cli/hosts.go b/internal/cli/hosts.go index ede0aa1..76ea451 100644 --- a/internal/cli/hosts.go +++ b/internal/cli/hosts.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "fmt" + "net" "os" "os/exec" "path/filepath" @@ -37,6 +38,9 @@ func runHostsSetup(args []string, keelDir string) { for i, a := range args { if a == "--ip" && i+1 < len(args) { ip = args[i+1] + if net.ParseIP(ip) == nil { + fatalf("invalid IP address: %s", ip) + } break } } diff --git a/internal/cli/runner.go b/internal/cli/runner.go index b4f578e..d25fe77 100644 --- a/internal/cli/runner.go +++ b/internal/cli/runner.go @@ -12,6 +12,7 @@ import ( "github.com/getkaze/keel/internal/config" "github.com/getkaze/keel/internal/model" + keelssh "github.com/getkaze/keel/internal/ssh" ) // Runner executes docker commands on a target (local or remote via SSH). @@ -122,7 +123,7 @@ func (r *Runner) Boot(ctx context.Context, svc model.Service, keelDir string) er } if svc.Registry == "ghcr" { - if err := r.ghcrLogin(ctx, keelDir); err != nil { + if err := r.GHCRLogin(ctx, keelDir); err != nil { return fmt.Errorf("ghcr: %w", err) } } @@ -130,7 +131,7 @@ func (r *Runner) Boot(ctx context.Context, svc model.Service, keelDir string) er // For remote targets, sync files to the remote host before boot // so that volume mounts can find them. if r.target.Mode != "local" && len(svc.Files) > 0 { - if err := r.syncFiles(ctx, svc, keelDir); err != nil { + if err := r.SyncFiles(ctx, svc, keelDir); err != nil { return fmt.Errorf("sync files: %w", err) } } @@ -139,18 +140,30 @@ func (r *Runner) Boot(ctx context.Context, svc model.Service, keelDir string) er return r.Exec(ctx, args...) } -// syncFiles copies service files to the remote host via scp so that +// SyncFiles copies service files to the remote host via scp so that // Docker volume mounts work. Each file entry has the format // "relative/path:/container/path"; we copy the local file to the same // absolute path (keelDir + relative) on the remote host. -func (r *Runner) syncFiles(ctx context.Context, svc model.Service, keelDir string) error { +func (r *Runner) SyncFiles(ctx context.Context, svc model.Service, keelDir string) error { for _, f := range svc.Files { parts := strings.SplitN(f, ":", 2) if len(parts) != 2 { continue } - localSrc := filepath.Join(keelDir, parts[0]) - remoteDst := filepath.Join(keelDir, parts[0]) + + // Validate the relative path does not escape keelDir via traversal. + cleaned := filepath.Clean(parts[0]) + if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + return fmt.Errorf("sync files: path traversal rejected: %s", parts[0]) + } + + localSrc := filepath.Join(keelDir, cleaned) + remoteDst := filepath.Join(keelDir, cleaned) + + // Double-check resolved paths are within keelDir. + if !strings.HasPrefix(localSrc, filepath.Clean(keelDir)+string(filepath.Separator)) { + return fmt.Errorf("sync files: resolved path outside keel dir: %s", localSrc) + } // Ensure parent directory exists on remote host and remove any // stale directory at the destination path (Docker creates a @@ -170,14 +183,14 @@ func (r *Runner) syncFiles(ctx context.Context, svc model.Service, keelDir strin } // Build scp args with the same SSH options (key, jump host). - scpArgs := []string{"-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", "-o", "LogLevel=ERROR"} + scpArgs := []string{"-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "LogLevel=ERROR"} if r.target.SSHKey != "" { - scpArgs = append(scpArgs, "-i", expandHome(r.target.SSHKey)) + scpArgs = append(scpArgs, "-i", keelssh.ExpandHome(r.target.SSHKey)) } if r.target.SSHJump != "" { - proxyCmd := "ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR" + proxyCmd := "ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o LogLevel=ERROR" if r.target.SSHKey != "" { - proxyCmd += " -i " + expandHome(r.target.SSHKey) + proxyCmd += " -i " + keelssh.ExpandHome(r.target.SSHKey) } proxyCmd += " -W %h:%p " + r.target.SSHJump scpArgs = append(scpArgs, "-o", "ProxyCommand="+proxyCmd) @@ -338,9 +351,9 @@ func (r *Runner) networkSubnet() string { return cfg.NetworkSubnet } -// ghcrLogin logs in to ghcr.io using credentials stored in keelDir/state/. +// GHCRLogin logs in to ghcr.io using credentials stored in keelDir/state/. // Only supported on local targets; silently skipped for remote targets. -func (r *Runner) ghcrLogin(ctx context.Context, keelDir string) error { +func (r *Runner) GHCRLogin(ctx context.Context, keelDir string) error { pat, err := os.ReadFile(filepath.Join(keelDir, "state/ghcr-pat")) if err != nil || len(bytes.TrimSpace(pat)) == 0 { return fmt.Errorf("PAT not found at %s/state/ghcr-pat", keelDir) @@ -364,17 +377,23 @@ func (r *Runner) ghcrLogin(ctx context.Context, keelDir string) error { return cmd.Run() } - // Remote: pipe PAT via SSH stdin into docker login on the target. + // Remote: pipe PAT via SSH stdin so it never appears in process args. sshArgs := r.buildSSHArgs() - sshArgs = append(sshArgs, fmt.Sprintf("echo %s | docker login ghcr.io -u %s --password-stdin", - shellQuote(string(ghcrPat)), shellQuote(ghcrUser))) + sshArgs = append(sshArgs, fmt.Sprintf("docker login ghcr.io -u %s --password-stdin", + shellQuote(ghcrUser))) cmd := exec.CommandContext(ctx, "ssh", sshArgs...) + cmd.Stdin = bytes.NewReader(append(ghcrPat, '\n')) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } -// DockerCmd implements docker.CmdBuilder, routing via SSH for remote targets. +// PortBind returns the address to bind container ports to for this target. +func (r *Runner) PortBind() string { + return r.target.PortBind +} + +// DockerCmd implements docker.CmdRunner, routing via SSH for remote targets. func (r *Runner) DockerCmd(ctx context.Context, args ...string) *exec.Cmd { return r.buildCmd(ctx, args...) } @@ -393,27 +412,7 @@ func (r *Runner) buildCmd(ctx context.Context, dockerArgs ...string) *exec.Cmd { // buildSSHArgs returns the SSH flags for the current target. func (r *Runner) buildSSHArgs() []string { - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "BatchMode=yes", - "-o", "LogLevel=ERROR", - } - if r.target.SSHKey != "" { - args = append(args, "-i", expandHome(r.target.SSHKey)) - } - if r.target.SSHJump != "" { - // Use ProxyCommand instead of -J so the identity key is also - // applied to the jump-host connection (ProxyJump/-J does not - // forward -i to the jump hop). - proxyCmd := "ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR" - if r.target.SSHKey != "" { - proxyCmd += " -i " + expandHome(r.target.SSHKey) - } - proxyCmd += " -W %h:%p " + r.target.SSHJump - args = append(args, "-o", "ProxyCommand="+proxyCmd) - } - args = append(args, r.target.SSHUser+"@"+r.target.Host) - return args + return keelssh.BuildArgs(r.target) } // buildRunArgs assembles the arguments for a `docker run` command from a service definition. @@ -455,7 +454,11 @@ func buildRunArgs(svc model.Service, keelDir, portBind string) []string { args = append(args, svc.Image) if svc.Command != "" { - args = append(args, strings.Fields(svc.Command)...) + if strings.ContainsAny(svc.Command, " \t\"'") { + args = append(args, "sh", "-c", svc.Command) + } else { + args = append(args, svc.Command) + } } return args } @@ -483,16 +486,6 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } -// expandHome expands a leading ~/ to the user's home directory. -func expandHome(path string) string { - if strings.HasPrefix(path, "~/") { - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, path[2:]) - } - } - return path -} - // shellJoin joins args into a shell-safe string for SSH execution. func shellJoin(args []string) string { parts := make([]string, len(args)) diff --git a/internal/docker/executor.go b/internal/docker/executor.go index 8c13df2..25774c9 100644 --- a/internal/docker/executor.go +++ b/internal/docker/executor.go @@ -6,8 +6,6 @@ import ( "context" "fmt" "log" - "os" - "os/exec" "path/filepath" "strings" "time" @@ -21,17 +19,20 @@ const ( longTimeout = 300 * time.Second ) -// Executor runs Docker operations directly. +// Executor runs Docker operations through a CmdRunner, which may target +// local or remote (SSH) Docker hosts transparently. type Executor struct { - Services *config.ServiceStore - KeelDir string + Services *config.ServiceStore + KeelDir string + Runner CmdRunner } // NewExecutor creates an Executor for the given keel data directory. -func NewExecutor(keelDir string, services *config.ServiceStore) *Executor { +func NewExecutor(keelDir string, services *config.ServiceStore, runner CmdRunner) *Executor { return &Executor{ - Services: services, - KeelDir: keelDir, + Services: services, + KeelDir: keelDir, + Runner: runner, } } @@ -93,7 +94,7 @@ func (e *Executor) startService(ctx context.Context, out chan<- string, name str } func (e *Executor) startOne(ctx context.Context, out chan<- string, svc *model.Service) error { - if isRunning(ctx, svc.Hostname) { + if e.isRunning(ctx, svc.Hostname) { emit(out, fmt.Sprintf("[%s] already running", svc.Name)) return nil } @@ -102,13 +103,13 @@ func (e *Executor) startOne(ctx context.Context, out chan<- string, svc *model.S if network == "" { network = "keel-net" } - if err := ensureNetwork(ctx, network, e.networkSubnet()); err != nil { + if err := e.ensureNetwork(ctx, network); err != nil { return fmt.Errorf("network: %w", err) } - if containerExists(ctx, svc.Hostname) { + if e.containerExists(ctx, svc.Hostname) { emit(out, fmt.Sprintf("[%s] starting", svc.Name)) - return dockerStream(ctx, out, "docker", "start", svc.Hostname) + return e.dockerStream(ctx, out, "start", svc.Hostname) } emit(out, fmt.Sprintf("[%s] booting", svc.Name)) @@ -129,12 +130,12 @@ func (e *Executor) stopService(ctx context.Context, out chan<- string, name stri } func (e *Executor) stopOne(ctx context.Context, out chan<- string, svc *model.Service) error { - if !isRunning(ctx, svc.Hostname) { + if !e.isRunning(ctx, svc.Hostname) { emit(out, fmt.Sprintf("[%s] not running", svc.Name)) return nil } emit(out, fmt.Sprintf("[%s] stopping", svc.Name)) - return dockerStream(ctx, out, "docker", "stop", svc.Hostname) + return e.dockerStream(ctx, out, "stop", svc.Hostname) } // --- update --- @@ -149,16 +150,20 @@ func (e *Executor) updateService(ctx context.Context, out chan<- string, name st } if svc.Registry == "ghcr" { - if err := e.ghcrLogin(ctx, out); err != nil { + emit(out, "logging in to ghcr.io") + if err := e.Runner.GHCRLogin(ctx, e.KeelDir); err != nil { return fmt.Errorf("ghcr: %w", err) } } emit(out, fmt.Sprintf("[%s] pulling %s", svc.Name, svc.Image)) - _ = dockerStream(ctx, out, "docker", "pull", svc.Image) + if err := e.dockerStream(ctx, out, "pull", svc.Image); err != nil { + emit(out, fmt.Sprintf("[%s] pull failed, keeping existing container: %v", svc.Name, err)) + return fmt.Errorf("pull %s: %w", svc.Image, err) + } emit(out, fmt.Sprintf("[%s] removing container", svc.Name)) - _ = dockerSilent(ctx, "docker", "rm", "-f", svc.Hostname) + _ = e.dockerSilent(ctx, "rm", "-f", svc.Hostname) emit(out, fmt.Sprintf("[%s] booting", svc.Name)) return e.boot(ctx, out, *svc) @@ -166,7 +171,7 @@ func (e *Executor) updateService(ctx context.Context, out chan<- string, name st // RemoveContainer stops and removes a container by hostname. func (e *Executor) RemoveContainer(ctx context.Context, hostname string) error { - return dockerSilent(ctx, "docker", "rm", "-f", hostname) + return e.dockerSilent(ctx, "rm", "-f", hostname) } // --- boot --- @@ -178,21 +183,36 @@ func (e *Executor) boot(ctx context.Context, out chan<- string, svc model.Servic } if svc.Registry == "ghcr" { - if err := e.ghcrLogin(ctx, out); err != nil { + emit(out, "logging in to ghcr.io") + if err := e.Runner.GHCRLogin(ctx, e.KeelDir); err != nil { return fmt.Errorf("ghcr: %w", err) } } + // Sync files to remote host before boot so volume mounts work. + if len(svc.Files) > 0 { + if err := e.Runner.SyncFiles(ctx, svc, e.KeelDir); err != nil { + return fmt.Errorf("sync files: %w", err) + } + } + + portBind := e.Runner.PortBind() + if portBind == "" { + portBind = "127.0.0.1" + } + args := []string{ "run", "-d", "--name", svc.Hostname, "--hostname", svc.Hostname, "--network", network, "--restart", "unless-stopped", + "--label", "keel.managed=true", + "--label", "keel.service=" + svc.Name, } if svc.Ports.External > 0 && svc.Ports.Internal > 0 { - args = append(args, "-p", fmt.Sprintf("127.0.0.1:%d:%d", svc.Ports.External, svc.Ports.Internal)) + args = append(args, "-p", fmt.Sprintf("%s:%d:%d", portBind, svc.Ports.External, svc.Ports.Internal)) } for k, v := range svc.Environment { @@ -211,10 +231,14 @@ func (e *Executor) boot(ctx context.Context, out chan<- string, svc model.Servic args = append(args, svc.Image) if svc.Command != "" { - args = append(args, strings.Fields(svc.Command)...) + if strings.ContainsAny(svc.Command, " \t\"'") { + args = append(args, "sh", "-c", svc.Command) + } else { + args = append(args, svc.Command) + } } - return dockerStream(ctx, out, append([]string{"docker"}, args...)...) + return e.dockerStream(ctx, out, args...) } // resolveVolume converts a relative bind-mount source to an absolute path. @@ -247,25 +271,26 @@ func (e *Executor) networkSubnet() string { return cfg.NetworkSubnet } -func ensureNetwork(ctx context.Context, network, subnet string) error { +func (e *Executor) ensureNetwork(ctx context.Context, network string) error { + subnet := e.networkSubnet() tctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // network inspect exits 0 if it exists, non-zero otherwise - if err := exec.CommandContext(tctx, "docker", "network", "inspect", network).Run(); err == nil { + if cmd := e.Runner.DockerCmd(tctx, "network", "inspect", network); cmd.Run() == nil { return nil } // Try with configured subnet first; if it conflicts, retry without it // (Docker will auto-assign a free subnet). if subnet != "" { - cmd := exec.CommandContext(tctx, "docker", "network", "create", "--driver", "bridge", "--subnet", subnet, network) + cmd := e.Runner.DockerCmd(tctx, "network", "create", "--driver", "bridge", "--subnet", subnet, network) if cmd.Run() == nil { return nil } log.Printf("network: subnet %s conflict, retrying without fixed subnet", subnet) } - cmd := exec.CommandContext(tctx, "docker", "network", "create", "--driver", "bridge", network) + cmd := e.Runner.DockerCmd(tctx, "network", "create", "--driver", "bridge", network) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { @@ -274,23 +299,25 @@ func ensureNetwork(ctx context.Context, network, subnet string) error { return nil } -func isRunning(ctx context.Context, hostname string) bool { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) +func (e *Executor) isRunning(ctx context.Context, hostname string) bool { + tctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - out, _ := exec.CommandContext(ctx, "docker", "ps", + cmd := e.Runner.DockerCmd(tctx, "ps", "--filter", "name=^/"+hostname+"$", "--format", "{{.Names}}", - ).Output() + ) + out, _ := cmd.Output() return strings.TrimSpace(string(out)) == hostname } -func containerExists(ctx context.Context, hostname string) bool { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) +func (e *Executor) containerExists(ctx context.Context, hostname string) bool { + tctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - out, _ := exec.CommandContext(ctx, "docker", "ps", "-a", + cmd := e.Runner.DockerCmd(tctx, "ps", "-a", "--filter", "name=^/"+hostname+"$", "--format", "{{.Names}}", - ).Output() + ) + out, _ := cmd.Output() return strings.TrimSpace(string(out)) == hostname } @@ -298,14 +325,15 @@ func emit(out chan<- string, msg string) { select { case out <- msg: default: + log.Printf("sse: dropped message (channel full): %s", msg) } } -func dockerStream(ctx context.Context, out chan<- string, args ...string) error { +func (e *Executor) dockerStream(ctx context.Context, out chan<- string, dockerArgs ...string) error { tctx, cancel := context.WithTimeout(ctx, longTimeout) defer cancel() - cmd := exec.CommandContext(tctx, args[0], args[1:]...) + cmd := e.Runner.DockerCmd(tctx, dockerArgs...) stdoutPipe, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("stdout pipe: %w", err) @@ -314,16 +342,42 @@ func dockerStream(ctx context.Context, out chan<- string, args ...string) error cmd.Stderr = &stderrBuf if err := cmd.Start(); err != nil { - return fmt.Errorf("start %s: %w", args[0], err) + return fmt.Errorf("start docker: %w", err) } - scanner := bufio.NewScanner(stdoutPipe) - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - case out <- scanner.Text(): + // Idle timeout: kill the process if no output is received for idleTimeout. + const idleTimeout = 10 * time.Minute + idle := time.NewTimer(idleTimeout) + defer idle.Stop() + + scanDone := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + idle.Reset(idleTimeout) + select { + case <-tctx.Done(): + scanDone <- tctx.Err() + return + case out <- scanner.Text(): + default: + log.Printf("sse: dropped stream line (channel full): %s", scanner.Text()) + } } + scanDone <- scanner.Err() + }() + + select { + case err := <-scanDone: + if err != nil { + return err + } + case <-idle.C: + log.Printf("docker stream: idle timeout (%v) — killing process", idleTimeout) + emit(out, "timeout: no output received, aborting") + cancel() + <-scanDone + return fmt.Errorf("docker stream idle timeout (%v)", idleTimeout) } if err := cmd.Wait(); err != nil { @@ -335,38 +389,14 @@ func dockerStream(ctx context.Context, out chan<- string, args ...string) error return nil } -func dockerSilent(ctx context.Context, args ...string) error { - ctx, cancel := context.WithTimeout(ctx, execTimeout) +func (e *Executor) dockerSilent(ctx context.Context, dockerArgs ...string) error { + tctx, cancel := context.WithTimeout(ctx, execTimeout) defer cancel() + cmd := e.Runner.DockerCmd(tctx, dockerArgs...) var stderr bytes.Buffer - cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) } return nil } - -func (e *Executor) ghcrLogin(ctx context.Context, out chan<- string) error { - patFile := filepath.Join(e.KeelDir, "state", "ghcr-pat") - userFile := filepath.Join(e.KeelDir, "state", "ghcr-user") - pat, err := os.ReadFile(patFile) - if err != nil || len(bytes.TrimSpace(pat)) == 0 { - return fmt.Errorf("PAT not found at %s", patFile) - } - user, err := os.ReadFile(userFile) - if err != nil || len(bytes.TrimSpace(user)) == 0 { - return fmt.Errorf("GitHub username not found at %s", userFile) - } - emit(out, "logging in to ghcr.io") - cmd := exec.CommandContext(ctx, "docker", "login", "ghcr.io", - "-u", strings.TrimSpace(string(user)), - "--password-stdin") - cmd.Stdin = bytes.NewReader(pat) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("ghcr login: %w: %s", err, strings.TrimSpace(stderr.String())) - } - return nil -} diff --git a/internal/server/routes.go b/internal/server/routes.go index 0101d03..6df2d7b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -48,22 +48,20 @@ func registerRoutes(mux *http.ServeMux, cfg Config) { // Shared dependencies services := config.NewServiceStore(cfg.KeelDir) poller := docker.NewStatusPoller() - executor := docker.NewExecutor(cfg.KeelDir, services) + executor := docker.NewExecutor(cfg.KeelDir, services, cfg.Runner) opMutex := handler.NewOpMutex() // Parse templates tmpl := parseTemplates(cfg) - // Read active target for header badge. - targetInfo, _ := config.ReadTarget(cfg.KeelDir) - targetName := "local" - if targetInfo != nil { - targetName = targetInfo.Name - } - // Page renderer. render := func(w http.ResponseWriter, page, serviceName string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + targetInfo, _ := config.ReadTarget(cfg.KeelDir) + targetName := "local" + if targetInfo != nil { + targetName = targetInfo.Name + } data := map[string]any{ "Version": cfg.Version, "Page": page, @@ -146,6 +144,9 @@ func registerRoutes(mux *http.ServeMux, cfg Config) { mux.Handle("GET /ws/terminal", &handler.TerminalHandler{}) mux.Handle("GET /ws/terminal/exec/{name}", &handler.ExecTerminalHandler{Services: services}) + // API: tunnel status (SSE) + mux.Handle("GET /api/tunnel/status", &handler.TunnelStatusHandler{Monitor: cfg.Tunnel}) + // API: target, health, metrics, version mux.Handle("GET /api/target", &handler.TargetHandler{KeelDir: cfg.KeelDir}) mux.Handle("GET /api/health", &handler.HealthHandler{Services: services, Docker: poller}) diff --git a/internal/server/server.go b/internal/server/server.go index 4eb46a4..1c475bd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,8 @@ import ( "time" "github.com/getkaze/keel/internal/config" + "github.com/getkaze/keel/internal/docker" + "github.com/getkaze/keel/internal/tunnel" ) // Config holds server configuration. @@ -23,6 +25,8 @@ type Config struct { Version string Ctx context.Context // Application-wide context, cancelled on shutdown Target *config.TargetConfig // Active target (nil = local) + Runner docker.CmdRunner // Docker command runner (local or remote) + Tunnel *tunnel.Monitor // SSH tunnel monitor (nil = local target) } // Server wraps the HTTP server with middleware and routing. diff --git a/main.go b/main.go index 5334f70..d9d3657 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "os" "os/exec" "os/signal" - "path/filepath" "strings" "syscall" "time" @@ -18,7 +17,9 @@ import ( "github.com/getkaze/keel/internal/cli" "github.com/getkaze/keel/internal/config" + "github.com/getkaze/keel/internal/docker" "github.com/getkaze/keel/internal/server" + "github.com/getkaze/keel/internal/tunnel" ) var version = "dev" @@ -66,22 +67,40 @@ func main() { // socket so that all `docker` commands from the dashboard executor // transparently reach the remote host. target, _ := config.ReadTargetConfig(keelDir) + var tunnelMon *tunnel.Monitor if target != nil && target.Mode == "remote" { - cleanup := startDockerTunnel(target) - defer cleanup() + tunnelMon = tunnel.NewMonitor(target) + if err := tunnelMon.Start(); err != nil { + log.Fatalf("docker tunnel: %v", err) + } + defer tunnelMon.Stop() + } + + // Create the appropriate CmdRunner based on the active target. + var inner docker.CmdRunner + if target != nil && target.Mode == "remote" { + inner = cli.NewRunner(target, keelDir) + } else { + inner = docker.NewLocalRunner() } + runner := docker.NewReloadableRunner(inner) srv := server.New(server.Config{ - Port: port, - Bind: bind, - Dev: dev, - KeelDir: keelDir, - StaticFS: embeddedFS, - Version: version, - Ctx: ctx, - Target: target, + Port: port, + Bind: bind, + Dev: dev, + KeelDir: keelDir, + StaticFS: embeddedFS, + Version: version, + Ctx: ctx, + Target: target, + Runner: runner, + Tunnel: tunnelMon, }) + // Watch target config for changes and hot-reload the runner. + go watchTargetConfig(ctx, keelDir, target, runner, &tunnelMon) + // Graceful shutdown on SIGINT/SIGTERM sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -111,71 +130,57 @@ func main() { log.Println("Server stopped") } -// startDockerTunnel opens an SSH tunnel that forwards the remote Docker socket -// to a local Unix socket. It sets DOCKER_HOST so all docker commands use it. -// Returns a cleanup function that kills the tunnel and removes the socket. -func startDockerTunnel(target *config.TargetConfig) func() { - sockPath := filepath.Join(os.TempDir(), fmt.Sprintf("keel-docker-%s.sock", target.Name)) - _ = os.Remove(sockPath) // remove stale socket - - sshArgs := []string{ - "-nNT", - "-o", "StrictHostKeyChecking=no", - "-o", "ExitOnForwardFailure=yes", - "-o", "LogLevel=ERROR", - "-L", sockPath + ":/var/run/docker.sock", - } - if target.SSHKey != "" { - keyPath := target.SSHKey - if strings.HasPrefix(keyPath, "~/") { - if home, err := os.UserHomeDir(); err == nil { - keyPath = filepath.Join(home, keyPath[2:]) - } - } - sshArgs = append(sshArgs, "-i", keyPath) - } - if target.SSHJump != "" { - keyPath := target.SSHKey - if strings.HasPrefix(keyPath, "~/") { - if home, err := os.UserHomeDir(); err == nil { - keyPath = filepath.Join(home, keyPath[2:]) - } - } - proxyCmd := "ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR" - if keyPath != "" { - proxyCmd += " -i " + keyPath - } - proxyCmd += " -W %h:%p " + target.SSHJump - sshArgs = append(sshArgs, "-o", "ProxyCommand="+proxyCmd) - } - sshArgs = append(sshArgs, target.SSHUser+"@"+target.Host) +// watchTargetConfig polls the target config files every 5 seconds and swaps +// the Runner when the active target changes. +func watchTargetConfig(ctx context.Context, keelDir string, current *config.TargetConfig, runner *docker.ReloadableRunner, tunnelMon **tunnel.Monitor) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() - cmd := exec.Command("ssh", sshArgs...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + configHash := targetHash(current) - if err := cmd.Start(); err != nil { - log.Fatalf("docker tunnel: failed to start SSH: %v", err) - } + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + newTarget, _ := config.ReadTargetConfig(keelDir) + h := targetHash(newTarget) + if h == configHash { + continue + } + configHash = h + log.Printf("target config changed, reloading (target=%s mode=%s)", newTarget.Name, newTarget.Mode) + + // Swap Runner. + if newTarget.Mode == "remote" { + runner.Swap(cli.NewRunner(newTarget, keelDir)) + } else { + runner.Swap(docker.NewLocalRunner()) + } - // Wait for the socket to appear (up to 5s). - for i := 0; i < 50; i++ { - if _, err := os.Stat(sockPath); err == nil { - break + // Handle tunnel lifecycle. + mon := *tunnelMon + if newTarget.Mode == "remote" && mon == nil { + newMon := tunnel.NewMonitor(newTarget) + if err := newMon.Start(); err != nil { + log.Printf("tunnel reload error: %v", err) + } else { + *tunnelMon = newMon + } + } else if newTarget.Mode != "remote" && mon != nil { + mon.Stop() + *tunnelMon = nil + } } - time.Sleep(100 * time.Millisecond) } +} - os.Setenv("DOCKER_HOST", "unix://"+sockPath) - log.Printf("docker tunnel: %s → %s@%s (socket: %s)", target.Name, target.SSHUser, target.Host, sockPath) - - return func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - _ = cmd.Wait() // Reap the process to avoid zombies - } - _ = os.Remove(sockPath) +// targetHash returns a simple string key for detecting config changes. +func targetHash(t *config.TargetConfig) string { + if t == nil { + return "local" } + return fmt.Sprintf("%s|%s|%s|%s|%s|%s", t.Name, t.Mode, t.Host, t.SSHUser, t.SSHKey, t.SSHJump) } func openBrowser(url string) { From 48bbeb7e620ab503d702404b0fd5f34d675eb728 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:43:55 -0300 Subject: [PATCH 06/25] feat: add TunnelMonitor with automatic reconnection and SSE status --- internal/handler/tunnel.go | 53 +++++++ internal/tunnel/monitor.go | 277 +++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 internal/handler/tunnel.go create mode 100644 internal/tunnel/monitor.go diff --git a/internal/handler/tunnel.go b/internal/handler/tunnel.go new file mode 100644 index 0000000..ccd5732 --- /dev/null +++ b/internal/handler/tunnel.go @@ -0,0 +1,53 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/getkaze/keel/internal/tunnel" +) + +// TunnelStatusHandler streams tunnel status changes as SSE events. +type TunnelStatusHandler struct { + Monitor *tunnel.Monitor // nil for local targets +} + +func (h *TunnelStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // For local targets, send "connected" and close. + if h.Monitor == nil { + fmt.Fprintf(w, "data: connected\n\n") + flusher.Flush() + return + } + + // Send current status immediately. + fmt.Fprintf(w, "data: %s\n\n", h.Monitor.Status()) + flusher.Flush() + + // Subscribe to status changes. + ch := h.Monitor.Subscribe() + defer h.Monitor.Unsubscribe(ch) + + for { + select { + case <-r.Context().Done(): + return + case status, ok := <-ch: + if !ok { + return + } + fmt.Fprintf(w, "data: %s\n\n", status) + flusher.Flush() + } + } +} diff --git a/internal/tunnel/monitor.go b/internal/tunnel/monitor.go new file mode 100644 index 0000000..0ec88c8 --- /dev/null +++ b/internal/tunnel/monitor.go @@ -0,0 +1,277 @@ +package tunnel + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "github.com/getkaze/keel/internal/config" + keelssh "github.com/getkaze/keel/internal/ssh" +) + +// Status represents the current state of the SSH tunnel. +type Status string + +const ( + StatusConnected Status = "connected" + StatusReconnecting Status = "reconnecting" + StatusFailed Status = "failed" + StatusDisconnected Status = "disconnected" +) + +const ( + healthInterval = 30 * time.Second + healthTimeout = 5 * time.Second + socketTimeout = 15 * time.Second + maxRetries = 10 + maxBackoff = 30 * time.Second +) + +// Monitor manages an SSH tunnel to a remote Docker socket with automatic +// reconnection and health checking. +type Monitor struct { + target *config.TargetConfig + sockPath string + + mu sync.RWMutex + status Status + listeners []chan Status + + ctx context.Context + cancel context.CancelFunc + cmd *exec.Cmd +} + +// NewMonitor creates a tunnel monitor for the given remote target. +func NewMonitor(target *config.TargetConfig) *Monitor { + ctx, cancel := context.WithCancel(context.Background()) + sockPath := filepath.Join(os.TempDir(), fmt.Sprintf("keel-docker-%s.sock", target.Name)) + + return &Monitor{ + target: target, + sockPath: sockPath, + status: StatusDisconnected, + ctx: ctx, + cancel: cancel, + } +} + +// Start establishes the initial tunnel connection and begins monitoring. +func (m *Monitor) Start() error { + if err := m.connect(); err != nil { + m.setStatus(StatusFailed) + return err + } + m.setStatus(StatusConnected) + os.Setenv("DOCKER_HOST", "unix://"+m.sockPath) + log.Printf("docker tunnel: %s → %s@%s (socket: %s)", m.target.Name, m.target.SSHUser, m.target.Host, m.sockPath) + + go m.monitor() + return nil +} + +// Stop shuts down the tunnel and stops monitoring. +func (m *Monitor) Stop() { + m.cancel() + m.killCmd() + _ = os.Remove(m.sockPath) + m.setStatus(StatusDisconnected) +} + +// Status returns the current tunnel status. +func (m *Monitor) Status() Status { + m.mu.RLock() + defer m.mu.RUnlock() + return m.status +} + +// Subscribe returns a channel that receives status changes. +func (m *Monitor) Subscribe() <-chan Status { + m.mu.Lock() + defer m.mu.Unlock() + ch := make(chan Status, 8) + m.listeners = append(m.listeners, ch) + return ch +} + +// Unsubscribe removes a previously subscribed channel. +func (m *Monitor) Unsubscribe(ch <-chan Status) { + m.mu.Lock() + defer m.mu.Unlock() + for i, l := range m.listeners { + if l == ch { + m.listeners = append(m.listeners[:i], m.listeners[i+1:]...) + close(l) + return + } + } +} + +func (m *Monitor) setStatus(s Status) { + m.mu.Lock() + prev := m.status + m.status = s + listeners := make([]chan Status, len(m.listeners)) + copy(listeners, m.listeners) + m.mu.Unlock() + + if s != prev { + log.Printf("docker tunnel: status %s → %s", prev, s) + for _, ch := range listeners { + select { + case ch <- s: + default: + } + } + } +} + +func (m *Monitor) connect() error { + _ = os.Remove(m.sockPath) // remove stale socket + + baseArgs := keelssh.BuildArgs(m.target) + sshArgs := []string{"-nNT", "-o", "ExitOnForwardFailure=yes", "-L", m.sockPath + ":/var/run/docker.sock"} + sshArgs = append(sshArgs, baseArgs...) + + cmd := exec.Command("ssh", sshArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("ssh tunnel start: %w", err) + } + + m.mu.Lock() + m.cmd = cmd + m.mu.Unlock() + + // Wait for socket to appear + deadline := time.Now().Add(socketTimeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(m.sockPath); err == nil { + return nil + } + select { + case <-m.ctx.Done(): + m.killCmd() + return m.ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + + m.killCmd() + return fmt.Errorf("tunnel socket did not appear within %s", socketTimeout) +} + +func (m *Monitor) monitor() { + // Watch for SSH process exit. + exitCh := make(chan error, 1) + go func() { + m.mu.RLock() + cmd := m.cmd + m.mu.RUnlock() + if cmd != nil { + exitCh <- cmd.Wait() + } + }() + + healthTicker := time.NewTicker(healthInterval) + defer healthTicker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + + case err := <-exitCh: + if m.ctx.Err() != nil { + return // shutting down + } + log.Printf("docker tunnel: SSH process exited: %v", err) + m.reconnect() + // Restart exit watcher for new process. + go func() { + m.mu.RLock() + cmd := m.cmd + m.mu.RUnlock() + if cmd != nil { + exitCh <- cmd.Wait() + } + }() + + case <-healthTicker.C: + if !m.healthCheck() { + log.Printf("docker tunnel: health check failed") + m.killCmd() + m.reconnect() + // Restart exit watcher for new process. + go func() { + m.mu.RLock() + cmd := m.cmd + m.mu.RUnlock() + if cmd != nil { + exitCh <- cmd.Wait() + } + }() + } + } + } +} + +func (m *Monitor) healthCheck() bool { + ctx, cancel := context.WithTimeout(m.ctx, healthTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}") + return cmd.Run() == nil +} + +func (m *Monitor) reconnect() { + m.setStatus(StatusReconnecting) + backoff := time.Second + + for attempt := 1; attempt <= maxRetries; attempt++ { + if m.ctx.Err() != nil { + return + } + + log.Printf("docker tunnel: reconnecting (attempt %d/%d, backoff %s)", attempt, maxRetries, backoff) + + select { + case <-m.ctx.Done(): + return + case <-time.After(backoff): + } + + if err := m.connect(); err != nil { + log.Printf("docker tunnel: reconnect failed: %v", err) + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + continue + } + + m.setStatus(StatusConnected) + log.Printf("docker tunnel: reconnected") + return + } + + m.setStatus(StatusFailed) + log.Printf("docker tunnel: failed after %d attempts", maxRetries) +} + +func (m *Monitor) killCmd() { + m.mu.Lock() + cmd := m.cmd + m.mu.Unlock() + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + _ = os.Remove(m.sockPath) +} From 96b5e241740ed6a6914abb73fd4dabeeb14e1b24 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:44:10 -0300 Subject: [PATCH 07/25] fix: resolve terminal deadlock and harden WebSocket origin check --- internal/handler/terminal.go | 48 +++++++++++++++++++++++++++--------- internal/terminal/pty.go | 29 +++++++++++----------- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/internal/handler/terminal.go b/internal/handler/terminal.go index 9feace0..581ae4d 100644 --- a/internal/handler/terminal.go +++ b/internal/handler/terminal.go @@ -5,6 +5,8 @@ import ( "io" "log" "net/http" + "net/url" + "strings" "sync" "github.com/gorilla/websocket" @@ -14,10 +16,33 @@ import ( ) var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - // Single-user local dashboard — allow all origins + CheckOrigin: checkWSOrigin, +} + +// checkWSOrigin validates the Origin header for WebSocket upgrade requests. +// Allows: same-host origin, localhost/127.0.0.1 on same port, empty origin (non-browser clients). +func checkWSOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true // non-browser clients + } + u, err := url.Parse(origin) + if err != nil { + return false + } + originHost := u.Hostname() + requestHost := r.Host + // Strip port from request host for comparison + if idx := strings.LastIndex(requestHost, ":"); idx != -1 { + requestHost = requestHost[:idx] + } + if originHost == requestHost { return true - }, + } + if originHost == "localhost" || originHost == "127.0.0.1" || originHost == "::1" { + return true + } + return false } // controlMessage is a JSON control frame for terminal resize. @@ -77,7 +102,12 @@ func serveTerminalWS(w http.ResponseWriter, r *http.Request, newSession func() ( websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "failed to create terminal session")) return } - defer sess.Close() + + // Close session on app context cancellation (server shutdown). + go func() { + <-r.Context().Done() + sess.Close() + }() // Mutex protects concurrent WebSocket writes from the PTY goroutine // and the main read loop (which sends close frames). @@ -111,7 +141,6 @@ func serveTerminalWS(w http.ResponseWriter, r *http.Request, newSession func() ( }() // WebSocket -> PTY stdin (binary) or control (text/JSON) - firstResize := true for { msgType, data, err := conn.ReadMessage() if err != nil { @@ -134,15 +163,12 @@ func serveTerminalWS(w http.ResponseWriter, r *http.Request, newSession func() ( } if ctrl.Type == "resize" && ctrl.Cols > 0 && ctrl.Rows > 0 { sess.Resize(ctrl.Rows, ctrl.Cols) - // After the first resize from the client, clear the screen - // so the prompt renders at the top with correct dimensions. - if firstResize { - firstResize = false - sess.ClearScreen() - } } } } + // Close the session BEFORE waiting for the reader goroutine. + // This causes PTY.Read() to return io.EOF, unblocking the reader. + sess.Close() wg.Wait() } diff --git a/internal/terminal/pty.go b/internal/terminal/pty.go index 7813368..f57dab8 100644 --- a/internal/terminal/pty.go +++ b/internal/terminal/pty.go @@ -3,14 +3,16 @@ package terminal import ( "os" "os/exec" + "sync" "github.com/creack/pty" ) // Session holds a PTY-attached bash process. type Session struct { - PTY *os.File - Cmd *exec.Cmd + PTY *os.File + Cmd *exec.Cmd + closeOnce sync.Once } // NewSession spawns /bin/bash with a PTY. @@ -58,18 +60,15 @@ func (s *Session) Resize(rows, cols uint16) error { }) } -// ClearScreen writes a clear command to the shell via PTY stdin. -func (s *Session) ClearScreen() { - s.PTY.Write([]byte("clear\n")) -} - -// Close terminates the session and cleans up. +// Close terminates the session and cleans up. Safe to call multiple times. func (s *Session) Close() { - if s.PTY != nil { - s.PTY.Close() - } - if s.Cmd != nil && s.Cmd.Process != nil { - s.Cmd.Process.Kill() - s.Cmd.Wait() - } + s.closeOnce.Do(func() { + if s.PTY != nil { + s.PTY.Close() + } + if s.Cmd != nil && s.Cmd.Process != nil { + s.Cmd.Process.Kill() + s.Cmd.Wait() + } + }) } From 2bbe6b4b4511584e7ca4984bc5ee529cb995a84d Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:44:48 -0300 Subject: [PATCH 08/25] fix: add remote metrics cache and SSH timeout --- internal/metrics/remote.go | 141 +++++++++++++++++++++++++++++-------- 1 file changed, 110 insertions(+), 31 deletions(-) diff --git a/internal/metrics/remote.go b/internal/metrics/remote.go index f0f5840..b8a1314 100644 --- a/internal/metrics/remote.go +++ b/internal/metrics/remote.go @@ -2,20 +2,41 @@ package metrics import ( "bytes" + "context" "fmt" - "os" "os/exec" - "path/filepath" "strconv" "strings" + "sync" + "time" "github.com/getkaze/keel/internal/config" "github.com/getkaze/keel/internal/model" + keelssh "github.com/getkaze/keel/internal/ssh" ) +const ( + cacheTTL = 10 * time.Second + sshExecTimeout = 5 * time.Second +) + +type cachedMetrics struct { + cpu model.CPUMetrics + mem model.MemoryMetrics + disk model.DiskMetrics + load model.LoadAvgMetrics + uptime model.UptimeMetrics + err error + at time.Time +} + // RemoteCollector reads system metrics from a remote host via SSH. type RemoteCollector struct { target *config.TargetConfig + + mu sync.RWMutex + cache *cachedMetrics + fetching bool } // NewRemoteCollector creates a collector for the given remote target. @@ -23,9 +44,90 @@ func NewRemoteCollector(target *config.TargetConfig) *RemoteCollector { return &RemoteCollector{target: target} } +// SetTarget updates the remote target and invalidates the cache. +func (rc *RemoteCollector) SetTarget(target *config.TargetConfig) { + rc.mu.Lock() + defer rc.mu.Unlock() + rc.target = target + rc.cache = nil + rc.fetching = false +} + // ReadAll collects CPU, memory, disk, load average, and uptime from the remote host -// in a single SSH call to minimize latency. +// in a single SSH call to minimize latency. Results are cached for 10 seconds; +// expired cache triggers a background refresh while returning stale data. func (rc *RemoteCollector) ReadAll() (model.CPUMetrics, model.MemoryMetrics, model.DiskMetrics, model.LoadAvgMetrics, model.UptimeMetrics, error) { + rc.mu.RLock() + c := rc.cache + rc.mu.RUnlock() + + if c != nil && time.Since(c.at) < cacheTTL { + return c.cpu, c.mem, c.disk, c.load, c.uptime, c.err + } + + // Cache expired or empty — check if a background fetch is already running. + rc.mu.Lock() + if rc.fetching { + // Another goroutine is already fetching; return stale data if available. + c = rc.cache + rc.mu.Unlock() + if c != nil { + return c.cpu, c.mem, c.disk, c.load, c.uptime, c.err + } + // No stale data — do a blocking fetch. + return rc.fetchAndCache() + } + + if rc.cache != nil && time.Since(rc.cache.at) < cacheTTL { + // Re-check after acquiring write lock — another goroutine may have refreshed. + c = rc.cache + rc.mu.Unlock() + return c.cpu, c.mem, c.disk, c.load, c.uptime, c.err + } + + hasStale := rc.cache != nil + rc.fetching = true + rc.mu.Unlock() + + if hasStale { + // Return stale data immediately, refresh in background. + go func() { + defer func() { + rc.mu.Lock() + rc.fetching = false + rc.mu.Unlock() + }() + rc.fetchAndCache() + }() + rc.mu.RLock() + c = rc.cache + rc.mu.RUnlock() + return c.cpu, c.mem, c.disk, c.load, c.uptime, c.err + } + + // First call ever — blocking fetch. + cpu, mem, disk, load, uptime, err := rc.fetchAndCache() + rc.mu.Lock() + rc.fetching = false + rc.mu.Unlock() + return cpu, mem, disk, load, uptime, err +} + +func (rc *RemoteCollector) fetchAndCache() (model.CPUMetrics, model.MemoryMetrics, model.DiskMetrics, model.LoadAvgMetrics, model.UptimeMetrics, error) { + cpu, mem, disk, load, uptime, err := rc.fetchRemote() + + rc.mu.Lock() + rc.cache = &cachedMetrics{ + cpu: cpu, mem: mem, disk: disk, + load: load, uptime: uptime, + err: err, at: time.Now(), + } + rc.mu.Unlock() + + return cpu, mem, disk, load, uptime, err +} + +func (rc *RemoteCollector) fetchRemote() (model.CPUMetrics, model.MemoryMetrics, model.DiskMetrics, model.LoadAvgMetrics, model.UptimeMetrics, error) { // Single command that reads all proc files + disk usage. // Two CPU samples 1 second apart for usage calculation. script := `cat /proc/stat | head -1; sleep 1; cat /proc/stat | head -1; cat /proc/meminfo; echo '---LOADAVG---'; cat /proc/loadavg; echo '---UPTIME---'; cat /proc/uptime; echo '---DISK---'; df -B1 / | tail -1` @@ -39,11 +141,14 @@ func (rc *RemoteCollector) ReadAll() (model.CPUMetrics, model.MemoryMetrics, mod } func (rc *RemoteCollector) sshExec(cmd string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), sshExecTimeout) + defer cancel() + args := buildSSHArgs(rc.target) args = append(args, cmd) var stdout, stderr bytes.Buffer - c := exec.Command("ssh", args...) + c := exec.CommandContext(ctx, "ssh", args...) c.Stdout = &stdout c.Stderr = &stderr @@ -54,33 +159,7 @@ func (rc *RemoteCollector) sshExec(cmd string) (string, error) { } func buildSSHArgs(t *config.TargetConfig) []string { - args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - } - if t.SSHKey != "" { - args = append(args, "-i", expandHome(t.SSHKey)) - } - if t.SSHJump != "" { - proxyCmd := "ssh -o StrictHostKeyChecking=no -o BatchMode=yes" - if t.SSHKey != "" { - proxyCmd += " -i " + expandHome(t.SSHKey) - } - proxyCmd += " -W %h:%p " + t.SSHJump - args = append(args, "-o", "ProxyCommand="+proxyCmd) - } - args = append(args, t.SSHUser+"@"+t.Host) - return args -} - -func expandHome(path string) string { - if strings.HasPrefix(path, "~/") { - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, path[2:]) - } - } - return path + return keelssh.BuildArgs(t) } func parseRemoteMetrics(raw string) (model.CPUMetrics, model.MemoryMetrics, model.DiskMetrics, model.LoadAvgMetrics, model.UptimeMetrics, error) { From 5833cfed046d2265c4cad1e5ae481b1a434847d1 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:45:21 -0300 Subject: [PATCH 09/25] =?UTF-8?q?fix:=20harden=20HTTP=20handlers=20?= =?UTF-8?q?=E2=80=94=20body=20limits,=20method=20corrections,=20JSON=20val?= =?UTF-8?q?idation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/health.go | 11 +++++- internal/handler/logs.go | 46 ++++++++++++++++++----- internal/handler/pages.go | 72 ++++++++++++++---------------------- internal/handler/seeders.go | 8 ++-- internal/handler/services.go | 34 +++++++++++++---- 5 files changed, 105 insertions(+), 66 deletions(-) diff --git a/internal/handler/health.go b/internal/handler/health.go index adb1949..a8d96a8 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -15,6 +15,14 @@ import ( "github.com/getkaze/keel/internal/model" ) +// healthHTTPClient is a shared client reused across all HTTP health checks. +var healthHTTPClient = &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + IdleConnTimeout: 30 * time.Second, + }, +} + // HealthResult holds the health check result for a single service. type HealthResult struct { Name string `json:"name"` @@ -102,12 +110,11 @@ func runCommandCheck(ctx context.Context, ci *docker.ContainerInfo, command stri } func runHTTPCheck(ctx context.Context, url string) (bool, string, error) { - client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return false, "", err } - resp, err := client.Do(req) + resp, err := healthHTTPClient.Do(req) if err != nil { log.Printf("health http check %s: %v", url, err) return false, "", nil diff --git a/internal/handler/logs.go b/internal/handler/logs.go index 129969e..48047c5 100644 --- a/internal/handler/logs.go +++ b/internal/handler/logs.go @@ -69,7 +69,7 @@ func (h *LogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if source != "" { logSource := findLogSource(svc.Logs, source) if logSource == nil { - fmt.Fprintf(w, "event: error\ndata: unknown log source: %s\n\n", source) + fmt.Fprintf(w, "event: app-error\ndata: unknown log source: %s\n\n", source) flusher.Flush() return } @@ -97,22 +97,40 @@ func (h *LogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Build docker command based on source type. var cmdArgs []string if filePath != "" { - cmdArgs = []string{"exec", containerName, "tail", "-n", strconv.Itoa(lines), "-f", filePath} + // Validate filePath: must contain no traversal and must be within a + // configured log source path for this service. + cleanFile := path.Clean(filePath) + if strings.Contains(cleanFile, "..") { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + allowed := false + for _, ls := range svc.Logs { + if ls.Path != "" && strings.HasPrefix(cleanFile, path.Clean(ls.Path)) { + allowed = true + break + } + } + if !allowed { + http.Error(w, "file path not within allowed log directory", http.StatusBadRequest) + return + } + cmdArgs = []string{"exec", containerName, "tail", "-n", strconv.Itoa(lines), "-f", cleanFile} } else if source != "" { logSource := findLogSource(svc.Logs, source) if logSource == nil { - fmt.Fprintf(w, "event: error\ndata: unknown log source: %s\n\n", source) + fmt.Fprintf(w, "event: app-error\ndata: unknown log source: %s\n\n", source) flusher.Flush() return } if logSource.Type != "file" || logSource.Path == "" { - fmt.Fprintf(w, "event: error\ndata: log source has no file path\n\n") + fmt.Fprintf(w, "event: app-error\ndata: log source has no file path\n\n") flusher.Flush() return } // Check if path is a directory — require a file selection. if isContainerDir(ctx, containerName, logSource.Path) { - fmt.Fprintf(w, "event: error\ndata: select a file from the dropdown\n\n") + fmt.Fprintf(w, "event: app-error\ndata: select a file from the dropdown\n\n") flusher.Flush() return } @@ -127,7 +145,7 @@ func (h *LogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func streamCmd(ctx context.Context, w http.ResponseWriter, flusher http.Flusher, cmd *exec.Cmd) { stdout, err := cmd.StdoutPipe() if err != nil { - fmt.Fprintf(w, "event: error\ndata: failed to create pipe: %s\n\n", err) + fmt.Fprintf(w, "event: app-error\ndata: failed to create pipe: %s\n\n", err) flusher.Flush() return } @@ -135,7 +153,7 @@ func streamCmd(ctx context.Context, w http.ResponseWriter, flusher http.Flusher, if err := cmd.Start(); err != nil { stdout.Close() - fmt.Fprintf(w, "event: error\ndata: failed to start: %s\n\n", err) + fmt.Fprintf(w, "event: app-error\ndata: failed to start: %s\n\n", err) flusher.Flush() return } @@ -146,7 +164,7 @@ func streamCmd(ctx context.Context, w http.ResponseWriter, flusher http.Flusher, for scanner.Scan() { select { case <-ctx.Done(): - fmt.Fprintf(w, "event: error\ndata: stream timeout\n\n") + fmt.Fprintf(w, "event: app-error\ndata: stream timeout\n\n") flusher.Flush() return default: @@ -298,8 +316,18 @@ func isContainerDir(ctx context.Context, containerName, path string) bool { } // isWithinDir returns true if p is inside (or equal to) dir. +// Symlinks are resolved before checking containment. func isWithinDir(p, dir string) bool { - rel, err := filepath.Rel(dir, p) + realP, err := filepath.EvalSymlinks(p) + if err != nil { + // If the file doesn't exist yet, fall back to Clean. + realP = filepath.Clean(p) + } + realDir, err := filepath.EvalSymlinks(dir) + if err != nil { + realDir = filepath.Clean(dir) + } + rel, err := filepath.Rel(realDir, realP) return err == nil && !strings.HasPrefix(rel, "..") } diff --git a/internal/handler/pages.go b/internal/handler/pages.go index aa26b73..e1937da 100644 --- a/internal/handler/pages.go +++ b/internal/handler/pages.go @@ -1,6 +1,7 @@ package handler import ( + "bytes" "encoding/json" "html/template" "log" @@ -25,6 +26,20 @@ type PageDeps struct { SeederExecutor *docker.SeederExecutor } +// renderTemplate executes a template into a buffer first. Only on success +// is the content written to w. On failure, a clean HTTP 500 is returned +// instead of partial HTML. +func (d *PageDeps) renderTemplate(w http.ResponseWriter, name string, data any) { + var buf bytes.Buffer + if err := d.Tmpl.ExecuteTemplate(&buf, name, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + buf.WriteTo(w) +} + // RegisterPageRoutes wires up page rendering routes. func RegisterPageRoutes(mux *http.ServeMux, deps *PageDeps) { mux.HandleFunc("GET /partials/overview", deps.overviewPartial) @@ -89,16 +104,12 @@ func (d *PageDeps) overviewPartial(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "service-grid", map[string]any{ + d.renderTemplate(w, "service-grid", map[string]any{ "Services": views, "Groups": groups, "HasRunning": hasRunning, "HasStopped": hasStopped, - }); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + }) } func (d *PageDeps) logsPartial(w http.ResponseWriter, r *http.Request) { @@ -114,14 +125,10 @@ func (d *PageDeps) logsPartial(w http.ResponseWriter, r *http.Request) { } servicesJSON, _ := json.Marshal(svcMap) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "log-viewer", map[string]any{ + d.renderTemplate(w, "log-viewer", map[string]any{ "Services": services, "ServicesJSON": template.JS(servicesJSON), - }); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + }) } // ServiceDetailView carries all data needed to render a container detail page. @@ -163,11 +170,7 @@ func (d *PageDeps) serviceDetailPartial(w http.ResponseWriter, r *http.Request) HasHostLogs: hasHostLogs, } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "service-detail", view); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + d.renderTemplate(w, "service-detail", view) } func (d *PageDeps) serviceNewPartial(w http.ResponseWriter, r *http.Request) { @@ -177,13 +180,9 @@ func (d *PageDeps) serviceNewPartial(w http.ResponseWriter, r *http.Request) { network = cfg.Network } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "service-new", map[string]any{ + d.renderTemplate(w, "service-new", map[string]any{ "DefaultNetwork": network, - }); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + }) } func (d *PageDeps) metricsPartial(w http.ResponseWriter, r *http.Request) { @@ -246,11 +245,7 @@ func (d *PageDeps) metricsPartial(w http.ResponseWriter, r *http.Request) { CPU: cpu, Memory: mem, Disk: disk, LoadAvg: loadAvg, Uptime: uptime, Containers: containers, } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "metrics-panel", data); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + d.renderTemplate(w, "metrics-panel", data) } func (d *PageDeps) metricsMiniPartial(w http.ResponseWriter, r *http.Request) { @@ -269,10 +264,7 @@ func (d *PageDeps) metricsMiniPartial(w http.ResponseWriter, r *http.Request) { } data := model.SystemMetrics{CPU: cpu, Memory: mem, Disk: disk} - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "metrics-mini", data); err != nil { - log.Printf("metrics-mini template error: %v", err) - } + d.renderTemplate(w, "metrics-mini", data) } // SeederGroupView groups seeders by their target service. @@ -333,21 +325,13 @@ func (d *PageDeps) seedersPartial(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "seeders", map[string]any{ + d.renderTemplate(w, "seeders", map[string]any{ "Groups": groups, - }); err != nil { - log.Printf("seeders template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + }) } func (d *PageDeps) settingsPartial(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := d.Tmpl.ExecuteTemplate(w, "settings", map[string]any{ + d.renderTemplate(w, "settings", map[string]any{ "Version": d.Version, - }); err != nil { - log.Printf("settings template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + }) } diff --git a/internal/handler/seeders.go b/internal/handler/seeders.go index a22b870..2088b66 100644 --- a/internal/handler/seeders.go +++ b/internal/handler/seeders.go @@ -31,8 +31,8 @@ type SeederDeps struct { // RegisterSeederRoutes wires up all seeder-related routes. func RegisterSeederRoutes(mux *http.ServeMux, deps *SeederDeps) { mux.HandleFunc("GET /api/seeders", deps.listSeeders) - mux.HandleFunc("GET /api/seeders/run", deps.runAll) - mux.HandleFunc("GET /api/seeders/run/{name}", deps.runOne) + mux.HandleFunc("POST /api/seeders/run", deps.runAll) + mux.HandleFunc("POST /api/seeders/run/{name}", deps.runOne) mux.HandleFunc("GET /api/seeders/config/{name}", deps.getSeederConfig) } @@ -114,7 +114,7 @@ func (d *SeederDeps) runAll(w http.ResponseWriter, r *http.Request) { } if err := <-errc; err != nil { - fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error()) + fmt.Fprintf(w, "event: app-error\ndata: %s\n\n", err.Error()) } else { fmt.Fprintf(w, "event: done\ndata: all seeders completed\n\n") } @@ -165,7 +165,7 @@ func (d *SeederDeps) runOne(w http.ResponseWriter, r *http.Request) { } if err := <-errc; err != nil { - fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error()) + fmt.Fprintf(w, "event: app-error\ndata: %s\n\n", err.Error()) } else { fmt.Fprintf(w, "event: done\ndata: seeder %s completed\n\n", name) } diff --git a/internal/handler/services.go b/internal/handler/services.go index 63a587c..038c16e 100644 --- a/internal/handler/services.go +++ b/internal/handler/services.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "html/template" + "io" "log" "net/http" "sort" @@ -47,8 +48,8 @@ func RegisterServiceRoutes(mux *http.ServeMux, deps *ServiceDeps) { mux.HandleFunc("POST /api/services/{name}/stop", deps.stopService) mux.HandleFunc("POST /api/services/{name}/restart", deps.restartService) mux.HandleFunc("POST /api/services/{name}/update", deps.updateService) - mux.HandleFunc("GET /api/services/start-all", deps.startAll) - mux.HandleFunc("GET /api/services/stop-all", deps.stopAll) + mux.HandleFunc("POST /api/services/start-all", deps.startAll) + mux.HandleFunc("POST /api/services/stop-all", deps.stopAll) mux.HandleFunc("GET /api/services/{name}/metrics", deps.getServiceMetrics) mux.HandleFunc("GET /api/services/{name}/config", deps.getServiceConfig) mux.HandleFunc("PUT /api/services/{name}/config", deps.saveServiceConfig) @@ -101,6 +102,15 @@ func (d *ServiceDeps) getService(w http.ResponseWriter, r *http.Request) { } func (d *ServiceDeps) createService(w http.ResponseWriter, r *http.Request) { + release := d.Mutex.TryAcquire(w, "create") + if release == nil { + return + } + defer release() + + // Limit body to 64 KB to prevent abuse. + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return @@ -305,7 +315,7 @@ func (d *ServiceDeps) startAll(w http.ResponseWriter, r *http.Request) { } if err := <-errc; err != nil { - fmt.Fprintf(w, "event: error\ndata: seeder failed: %s\n\n", err.Error()) + fmt.Fprintf(w, "event: app-error\ndata: seeder failed: %s\n\n", err.Error()) flusher.Flush() d.Docker.Invalidate() return // STOP — don't start app services @@ -483,13 +493,18 @@ func (d *ServiceDeps) getServiceConfig(w http.ResponseWriter, r *http.Request) { func (d *ServiceDeps) saveServiceConfig(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") + // Limit body to 1 MB to prevent abuse. + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + // Accept raw JSON body OR form field "config" var data []byte ct := r.Header.Get("Content-Type") if strings.Contains(ct, "application/json") { - var buf strings.Builder - if _, err := fmt.Fscan(r.Body, &buf); err == nil { - data = []byte(buf.String()) + var err error + data, err = io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return } } else { r.ParseForm() @@ -501,6 +516,11 @@ func (d *ServiceDeps) saveServiceConfig(w http.ResponseWriter, r *http.Request) return } + if !json.Valid(data) { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if err := d.Services.SaveRaw(name, data); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -529,7 +549,7 @@ func (d *ServiceDeps) streamCommand(w http.ResponseWriter, r *http.Request, comm } if err := <-errc; err != nil { - fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error()) + fmt.Fprintf(w, "event: app-error\ndata: %s\n\n", err.Error()) } else { fmt.Fprintf(w, "event: done\ndata: %s %s completed\n\n", command, args) } From 174aee4f9d62a7800e410e1e224109bce5d396eb Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:45:47 -0300 Subject: [PATCH 10/25] feat: add semver comparison and cross-device-safe updater --- internal/updater/updater.go | 49 +++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index cd5ee2e..de70afa 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -5,7 +5,9 @@ import ( "io" "net/http" "os" + "path/filepath" "runtime" + "strconv" "strings" "time" ) @@ -44,11 +46,48 @@ func Check(current string) (*CheckResult, error) { Current: current, Latest: latest, UpdateURL: downloadURL(latest), - Available: latest != current && current != "dev", + Available: current != "dev" && IsNewer(latest, current), }, nil } +// IsNewer reports whether version a is semantically newer than version b. +// Versions are expected as "major.minor.patch" (leading "v" is stripped). +// Falls back to string comparison if parsing fails. +func IsNewer(a, b string) bool { + aParts, aOk := parseSemver(a) + bParts, bOk := parseSemver(b) + if !aOk || !bOk { + return a != b + } + for i := 0; i < 3; i++ { + if aParts[i] != bParts[i] { + return aParts[i] > bParts[i] + } + } + return false +} + +// parseSemver splits "major.minor.patch" into [3]int. +func parseSemver(v string) ([3]int, bool) { + v = strings.TrimPrefix(v, "v") + parts := strings.SplitN(v, ".", 3) + if len(parts) != 3 { + return [3]int{}, false + } + var result [3]int + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return [3]int{}, false + } + result[i] = n + } + return result, true +} + // Download fetches the latest binary to a temp file and returns its path. +// The temp file is created in the same directory as the running binary +// to avoid cross-device rename errors when /tmp is on a different filesystem. func Download(version string) (string, error) { url := downloadURL(version) @@ -63,7 +102,13 @@ func Download(version string) (string, error) { return "", fmt.Errorf("download: status %d", resp.StatusCode) } - tmp, err := os.CreateTemp("", "keel-update-*") + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("find executable: %w", err) + } + targetDir := filepath.Dir(exe) + + tmp, err := os.CreateTemp(targetDir, "keel-update-*") if err != nil { return "", fmt.Errorf("create temp: %w", err) } From 95087f9811948cabbd976de9b78b5cab90a925ec Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:46:11 -0300 Subject: [PATCH 11/25] fix: client-side terminal clear, SSE POST streams, toast error details --- web/static/app.js | 229 +++++++++++++++++---- web/static/style.css | 16 +- web/templates/layout.html | 21 +- web/templates/partials/log-viewer.html | 2 +- web/templates/partials/service-grid.html | 4 +- web/templates/partials/terminal-panel.html | 6 +- 6 files changed, 217 insertions(+), 61 deletions(-) diff --git a/web/static/app.js b/web/static/app.js index c29f7ea..34fc347 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -115,50 +115,132 @@ function confirmAction(title, message, confirmText, confirmClass, onConfirm) { modal.showModal(); } -// SSE stream handler for operation progress +// SSE stream handler for operation progress (supports both GET and POST) function startSSE(url, opts) { opts = opts || {}; + var method = opts.method || 'GET'; var panel = document.getElementById('operation-output'); if (panel) { panel.innerHTML = ''; document.getElementById('operation-panel').classList.remove('hidden'); } - var source = new EventSource(url); + if (method === 'GET') { + var source = new EventSource(url); + + source.onmessage = function(e) { + if (!panel) return; + var converter = getAnsiUp(); + var line = document.createElement('div'); + line.innerHTML = safeAnsiHtml(converter, e.data); + panel.appendChild(line); + panel.scrollTop = panel.scrollHeight; + }; + + source.addEventListener('done', function(e) { + source.close(); + if (panel) { + var banner = document.createElement('div'); + banner.className = 'text-success font-semibold mt-2'; + banner.textContent = e.data || 'Operation completed successfully'; + panel.appendChild(banner); + } + showToast(opts.successMessage || 'Operation completed', 'success'); + htmx.trigger(document.body, 'refreshServices'); + }); - source.onmessage = function(e) { - if (!panel) return; - var converter = getAnsiUp(); - var line = document.createElement('div'); - line.innerHTML = safeAnsiHtml(converter, e.data); - panel.appendChild(line); - panel.scrollTop = panel.scrollHeight; - }; + source.addEventListener('app-error', function(e) { + source.close(); + if (panel) { + var banner = document.createElement('div'); + banner.className = 'text-error font-semibold mt-2'; + banner.textContent = e.data || 'Operation failed'; + panel.appendChild(banner); + } + showToast(opts.errorMessage || 'Operation failed', 'error'); + }); + + return source; + } + + // POST-based SSE: use fetch + ReadableStream + _fetchSSE(url, method, panel, opts); +} + +function _fetchSSE(url, method, panel, opts) { + fetch(url, { method: method }).then(function(response) { + if (!response.ok) { + showToast(opts.errorMessage || 'Operation failed', 'error'); + return; + } + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + + function processChunk(result) { + if (result.done) { + // Process remaining buffer + if (buffer.trim()) _parseSSEBuffer(buffer, panel, opts); + return; + } + buffer += decoder.decode(result.value, { stream: true }); + // Split on double newlines (SSE frame boundary) + var frames = buffer.split('\n\n'); + buffer = frames.pop(); // keep incomplete frame + for (var i = 0; i < frames.length; i++) { + _parseSSEFrame(frames[i].trim(), panel, opts); + } + return reader.read().then(processChunk); + } - source.addEventListener('done', function(e) { - source.close(); + return reader.read().then(processChunk); + }).catch(function() { + showToast(opts.errorMessage || 'Connection failed', 'error'); + }); +} + +function _parseSSEBuffer(buf, panel, opts) { + var frames = buf.split('\n\n'); + for (var i = 0; i < frames.length; i++) { + if (frames[i].trim()) _parseSSEFrame(frames[i].trim(), panel, opts); + } +} + +function _parseSSEFrame(frame, panel, opts) { + if (!frame) return; + var event = 'message'; + var data = ''; + var lines = frame.split('\n'); + for (var i = 0; i < lines.length; i++) { + if (lines[i].indexOf('event: ') === 0) event = lines[i].substring(7).trim(); + else if (lines[i].indexOf('data: ') === 0) data = lines[i].substring(6); + } + + if (event === 'done') { if (panel) { var banner = document.createElement('div'); banner.className = 'text-success font-semibold mt-2'; - banner.textContent = e.data || 'Operation completed successfully'; + banner.textContent = data || 'Operation completed successfully'; panel.appendChild(banner); } showToast(opts.successMessage || 'Operation completed', 'success'); htmx.trigger(document.body, 'refreshServices'); - }); - - source.addEventListener('error', function(e) { - source.close(); + } else if (event === 'app-error') { if (panel) { var banner = document.createElement('div'); banner.className = 'text-error font-semibold mt-2'; - banner.textContent = e.data || 'Operation failed'; + banner.textContent = data || 'Operation failed'; panel.appendChild(banner); } showToast(opts.errorMessage || 'Operation failed', 'error'); - }); - - return source; + } else { + if (!panel) return; + var converter = getAnsiUp(); + var line = document.createElement('div'); + line.innerHTML = safeAnsiHtml(converter, data); + panel.appendChild(line); + panel.scrollTop = panel.scrollHeight; + } } // Keyboard shortcuts @@ -199,10 +281,11 @@ function connectToContainer(name) { // Navigate to logs page with a specific service pre-selected function navigateToLogs(serviceName) { htmx.ajax('GET', '/partials/logs', {target: '#main-content', swap: 'innerHTML'}).then(function() { - // Wait for Alpine to initialize the log-viewer component, then dispatch event - setTimeout(function() { + // Dispatch after HTMX finishes settling the swapped DOM + document.addEventListener('htmx:afterSettle', function onSettle() { + document.removeEventListener('htmx:afterSettle', onSettle); document.dispatchEvent(new CustomEvent('open-logs', { detail: { service: serviceName } })); - }, 100); + }); }); history.pushState(null, '', '/logs'); setActiveNav('/logs'); @@ -236,7 +319,21 @@ document.addEventListener('htmx:afterRequest', function(e) { if (!action || !actionMessages[action]) return; if (e.detail.successful) { - showToast(actionMessages[action].ok, 'success'); + // SSE streams return HTTP 200 even on failure — check the response + // body for "event: app-error" to detect errors inside the stream. + var responseText = e.detail.xhr.responseText || ''; + var errorIdx = responseText.indexOf('event: app-error'); + if (errorIdx !== -1) { + var reason = ''; + var dataPrefix = responseText.indexOf('data: ', errorIdx); + if (dataPrefix !== -1) { + var lineEnd = responseText.indexOf('\n', dataPrefix); + reason = responseText.substring(dataPrefix + 6, lineEnd !== -1 ? lineEnd : undefined).trim(); + } + showToast(actionMessages[action].fail + (reason ? ': ' + reason : ''), 'error'); + } else { + showToast(actionMessages[action].ok, 'success'); + } } }); @@ -361,7 +458,7 @@ function seederSSE(url, btn, name) { if (out) { out.innerHTML = ''; sessionStorage.removeItem('seederLog-' + name); } showSeederLog(name); } else { - document.querySelectorAll('.seeder-card').forEach(function(c) { + document.querySelectorAll('.seeder-item').forEach(function(c) { var n = c.id.replace('seeder-card-', ''); setSeederStatus(n, 'running'); var out = document.getElementById('seeder-log-output-' + n); @@ -370,54 +467,98 @@ function seederSSE(url, btn, name) { }); } - var source = new EventSource(url); - - source.onmessage = function(e) { + function onData(data) { if (name) { - appendSeederLog(name, e.data); + appendSeederLog(name, data); } else { - var match = e.data.match(/^\[([^\]]+)\]/); - if (match) appendSeederLog(match[1], e.data); + var match = data.match(/^\[([^\]]+)\]/); + if (match) appendSeederLog(match[1], data); } - }; + } - source.addEventListener('done', function(e) { - source.close(); + function onDone(data) { btn.disabled = false; if (btn.querySelector('span')) btn.querySelector('span').textContent = originalText; if (name) { setSeederStatus(name, 'success'); - appendSeederLog(name, '✓ ' + (e.data || 'completed')); + appendSeederLog(name, '✓ ' + (data || 'completed')); showToast('Seeder completed', 'success'); } else { - document.querySelectorAll('.seeder-card').forEach(function(c) { + document.querySelectorAll('.seeder-item').forEach(function(c) { setSeederStatus(c.id.replace('seeder-card-', ''), 'success'); }); showToast('Seeders completed', 'success'); } - }); + } - source.addEventListener('error', function(e) { - source.close(); + function onError(data) { btn.disabled = false; if (btn.querySelector('span')) btn.querySelector('span').textContent = originalText; showToast('Seeder failed', 'error'); if (name) { setSeederStatus(name, 'error'); - appendSeederLog(name, '✗ ' + (e.data || 'failed')); + appendSeederLog(name, '✗ ' + (data || 'failed')); } else { - var errMatch = e.data ? e.data.match(/seeder "([^"]+)"/) : null; + var errMatch = data ? data.match(/seeder "([^"]+)"/) : null; var failedName = errMatch ? errMatch[1] : null; - document.querySelectorAll('.seeder-card').forEach(function(c) { + document.querySelectorAll('.seeder-item').forEach(function(c) { var n = c.id.replace('seeder-card-', ''); if (n === failedName) { setSeederStatus(n, 'error'); - appendSeederLog(n, '✗ ' + (e.data || 'failed')); + appendSeederLog(n, '✗ ' + (data || 'failed')); } else if (c.classList.contains('seeder-running')) { setSeederStatus(n, 'idle'); } }); } + } + + fetch(url, { method: 'POST' }).then(function(response) { + if (!response.ok) { + onError('request failed: ' + response.status); + return; + } + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + + function processChunk(result) { + if (result.done) { + if (buffer.trim()) processFrames(buffer); + return; + } + buffer += decoder.decode(result.value, { stream: true }); + var frames = buffer.split('\n\n'); + buffer = frames.pop(); + for (var i = 0; i < frames.length; i++) { + if (frames[i].trim()) processFrame(frames[i].trim()); + } + return reader.read().then(processChunk); + } + + function processFrames(buf) { + var parts = buf.split('\n\n'); + for (var i = 0; i < parts.length; i++) { + if (parts[i].trim()) processFrame(parts[i].trim()); + } + } + + function processFrame(frame) { + var event = 'message'; + var data = ''; + var lines = frame.split('\n'); + for (var i = 0; i < lines.length; i++) { + if (lines[i].indexOf('event: ') === 0) event = lines[i].substring(7).trim(); + else if (lines[i].indexOf('data: ') === 0) data = lines[i].substring(6); + } + if (event === 'done') onDone(data); + else if (event === 'app-error') onError(data); + else onData(data); + } + + return reader.read().then(processChunk); + }).catch(function() { + onError('connection failed'); }); } diff --git a/web/static/style.css b/web/static/style.css index 4f30de6..49b8688 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1577,18 +1577,10 @@ article.card, .service-card { .seeder-status.pending { color: var(--muted2); } .seeder-status.running { color: var(--yellow); } -/* Legacy seeder card compat */ -.seeder-card { - background: var(--surface); - border-radius: 6px; - border: 1px solid var(--border); - transition: background .15s; -} -.seeder-card:hover { background: var(--surface2); } -.seeder-card.seeder-idle { border-left: 2px solid var(--muted2); } -.seeder-card.seeder-running { border-left: 2px solid var(--yellow); } -.seeder-card.seeder-success { border-left: 2px solid var(--green); } -.seeder-card.seeder-error { border-left: 2px solid var(--red); } +.seeder-item.seeder-idle { border-left: 2px solid var(--muted2); } +.seeder-item.seeder-running { border-left: 2px solid var(--yellow); } +.seeder-item.seeder-success { border-left: 2px solid var(--green); } +.seeder-item.seeder-error { border-left: 2px solid var(--red); } /* ══════════════════════════════════════════ 15. TARGETS VIEW diff --git a/web/templates/layout.html b/web/templates/layout.html index 16f6ff2..3a4ca71 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -50,7 +50,7 @@
- + {{.TargetName}}
updateThemeIcons(saved); } })(); + + // Tunnel status indicator (SSE) + (function() { + var dot = document.getElementById('tunnel-dot'); + if (!dot) return; + var colors = { + connected: 'var(--green)', + reconnecting: 'var(--yellow, #e2b93d)', + failed: 'var(--red, #e25c5c)', + disconnected: 'var(--muted, #888)' + }; + var es = new EventSource('/api/tunnel/status'); + es.onmessage = function(e) { + var c = colors[e.data] || colors.connected; + dot.style.background = c; + dot.style.boxShadow = '0 0 6px ' + c; + dot.title = 'Tunnel: ' + e.data; + }; + })(); diff --git a/web/templates/partials/log-viewer.html b/web/templates/partials/log-viewer.html index 4c58715..60adf23 100644 --- a/web/templates/partials/log-viewer.html +++ b/web/templates/partials/log-viewer.html @@ -222,7 +222,7 @@ } }; - this.eventSource.addEventListener('error', (e) => { + this.eventSource.addEventListener('app-error', (e) => { this.streaming = false; if (e.data) { var line = document.createElement('div'); diff --git a/web/templates/partials/service-grid.html b/web/templates/partials/service-grid.html index 9e76bbe..cf2203a 100644 --- a/web/templates/partials/service-grid.html +++ b/web/templates/partials/service-grid.html @@ -10,14 +10,14 @@
{{if .HasStopped}} {{end}} {{if .HasRunning}} diff --git a/web/templates/partials/terminal-panel.html b/web/templates/partials/terminal-panel.html index 67afd39..87b8e7d 100644 --- a/web/templates/partials/terminal-panel.html +++ b/web/templates/partials/terminal-panel.html @@ -307,7 +307,11 @@ tab.connected = true; self.tabs = self.tabs.slice(); self.sendResize(tab); - if (self.activeTabId === tabId && tab.term) { + // Clear screen client-side so the prompt renders clean + // at the top. Done here (not server-side) to avoid a race + // where ANSI escapes arrive before xterm.js is ready. + if (tab.term) { + tab.term.clear(); tab.term.focus(); } }; From 61b15f8a8c24399c4a3a9c76c339b256a4814c94 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:46:31 -0300 Subject: [PATCH 12/25] ci: add CI workflow and add tests to release workflow --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ .github/workflows/release.yml | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a342159 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... -race -count=1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be703b8..96cc760 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,25 @@ permissions: contents: write jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... -race -count=1 + build: + needs: test name: Build (${{ matrix.os }}/${{ matrix.arch }}) runs-on: ubuntu-latest strategy: From 2c3f3885e4433633b4c7e5bc6a8f3c20f78e4f50 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:46:49 -0300 Subject: [PATCH 13/25] test: add 155 unit tests across all packages --- internal/cli/runner_test.go | 242 +++++++++++++++++++++ internal/config/seeders_test.go | 147 +++++++++++++ internal/config/target_test.go | 213 +++++++++++++++++++ internal/docker/executor_test.go | 335 ++++++++++++++++++++++++++++++ internal/docker/seeder_test.go | 183 ++++++++++++++++ internal/docker/status_test.go | 42 ++++ internal/handler/services_test.go | 120 +++++++++++ internal/handler/terminal_test.go | 80 +++++++ internal/metrics/metrics_test.go | 75 +++++++ internal/metrics/remote_test.go | 150 +++++++++++++ internal/updater/updater_test.go | 94 +++++++++ 11 files changed, 1681 insertions(+) create mode 100644 internal/cli/runner_test.go create mode 100644 internal/config/seeders_test.go create mode 100644 internal/config/target_test.go create mode 100644 internal/docker/executor_test.go create mode 100644 internal/docker/seeder_test.go create mode 100644 internal/handler/services_test.go create mode 100644 internal/handler/terminal_test.go create mode 100644 internal/metrics/metrics_test.go create mode 100644 internal/metrics/remote_test.go create mode 100644 internal/updater/updater_test.go diff --git a/internal/cli/runner_test.go b/internal/cli/runner_test.go new file mode 100644 index 0000000..abc9f5f --- /dev/null +++ b/internal/cli/runner_test.go @@ -0,0 +1,242 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/getkaze/keel/internal/model" +) + +// --- buildRunArgs tests --- + +func TestBuildRunArgs_Basic(t *testing.T) { + svc := model.Service{ + Name: "mysql", + Hostname: "keel-mysql", + Image: "mysql:8", + Ports: model.PortConfig{External: 3306, Internal: 3306}, + } + args := buildRunArgs(svc, "/opt/keel", "127.0.0.1") + + assertContains(t, args, "run") + assertContains(t, args, "-d") + assertContainsPair(t, args, "--name", "keel-mysql") + assertContainsPair(t, args, "--hostname", "keel-mysql") + assertContainsPair(t, args, "--network", "keel-net") + assertContainsPair(t, args, "-p", "127.0.0.1:3306:3306") +} + +func TestBuildRunArgs_CustomNetwork(t *testing.T) { + svc := model.Service{ + Name: "app", + Hostname: "keel-app", + Image: "app:latest", + Network: "my-net", + } + args := buildRunArgs(svc, "/opt/keel", "") + assertContainsPair(t, args, "--network", "my-net") +} + +func TestBuildRunArgs_DefaultPortBind(t *testing.T) { + svc := model.Service{ + Name: "app", + Hostname: "keel-app", + Image: "app:latest", + Ports: model.PortConfig{External: 8080, Internal: 80}, + } + args := buildRunArgs(svc, "/opt/keel", "") + assertContainsPair(t, args, "-p", "127.0.0.1:8080:80") +} + +func TestBuildRunArgs_NoPorts(t *testing.T) { + svc := model.Service{ + Name: "worker", + Hostname: "keel-worker", + Image: "worker:latest", + } + args := buildRunArgs(svc, "/opt/keel", "") + for _, a := range args { + if a == "-p" { + t.Error("should not have -p flag when no ports") + } + } +} + +func TestBuildRunArgs_CommandWithSpaces(t *testing.T) { + svc := model.Service{ + Name: "app", + Hostname: "keel-app", + Image: "app:latest", + Command: "npm run start", + } + args := buildRunArgs(svc, "/opt/keel", "") + n := len(args) + if n < 3 || args[n-3] != "sh" || args[n-2] != "-c" || args[n-1] != "npm run start" { + t.Errorf("expected 'sh -c npm run start' at end, got: %v", args[n-3:]) + } +} + +func TestBuildRunArgs_SimpleCommand(t *testing.T) { + svc := model.Service{ + Name: "app", + Hostname: "keel-app", + Image: "app:latest", + Command: "server", + } + args := buildRunArgs(svc, "/opt/keel", "") + if args[len(args)-1] != "server" { + t.Errorf("expected 'server' as last arg") + } +} + +func TestBuildRunArgs_Environment(t *testing.T) { + svc := model.Service{ + Name: "app", + Hostname: "keel-app", + Image: "app:latest", + Environment: map[string]string{"DB_HOST": "localhost"}, + } + args := buildRunArgs(svc, "/opt/keel", "") + assertContainsPair(t, args, "-e", "DB_HOST=localhost") +} + +// --- resolveVolume tests --- + +func TestResolveVolume_NamedVolume(t *testing.T) { + got := resolveVolume("mydata:/var/lib/data", "/opt/keel") + if got != "mydata:/var/lib/data" { + t.Errorf("expected unchanged, got %q", got) + } +} + +func TestResolveVolume_AbsolutePath(t *testing.T) { + got := resolveVolume("/host/path:/container/path", "/opt/keel") + if got != "/host/path:/container/path" { + t.Errorf("expected unchanged, got %q", got) + } +} + +func TestResolveVolume_RelativePath(t *testing.T) { + got := resolveVolume("./data:/var/lib/data", "/opt/keel") + if got != "/opt/keel/data:/var/lib/data" { + t.Errorf("expected /opt/keel/data:/var/lib/data, got %q", got) + } +} + +func TestResolveVolume_RelativeNoDot(t *testing.T) { + got := resolveVolume("configs/my.cnf:/etc/my.cnf", "/opt/keel") + if got != "/opt/keel/configs/my.cnf:/etc/my.cnf" { + t.Errorf("expected /opt/keel/configs/my.cnf:/etc/my.cnf, got %q", got) + } +} + +// --- shellQuote tests --- + +func TestShellQuote_Simple(t *testing.T) { + got := shellQuote("hello") + if got != "'hello'" { + t.Errorf("expected 'hello', got %q", got) + } +} + +func TestShellQuote_WithSingleQuotes(t *testing.T) { + got := shellQuote("it's") + if !strings.Contains(got, `'\''`) { + t.Errorf("expected escaped single quote, got %q", got) + } +} + +func TestShellQuote_Empty(t *testing.T) { + got := shellQuote("") + if got != "''" { + t.Errorf("expected empty quotes, got %q", got) + } +} + +// --- shellJoin tests --- + +func TestShellJoin_SimpleArgs(t *testing.T) { + got := shellJoin([]string{"docker", "ps", "-a"}) + if got != "docker ps -a" { + t.Errorf("expected 'docker ps -a', got %q", got) + } +} + +func TestShellJoin_ArgsWithSpaces(t *testing.T) { + got := shellJoin([]string{"echo", "hello world"}) + if !strings.Contains(got, "'hello world'") { + t.Errorf("expected quoted 'hello world', got %q", got) + } +} + +func TestShellJoin_ArgsWithSpecialChars(t *testing.T) { + got := shellJoin([]string{"docker", "run", "-e", "FOO=$BAR"}) + // FOO=$BAR should be quoted because of $ + if !strings.Contains(got, "'FOO=$BAR'") { + t.Errorf("expected quoted 'FOO=$BAR', got %q", got) + } +} + +// --- workdirFromDockerfile tests --- + +func TestWorkdirFromDockerfile_Found(t *testing.T) { + lines := []string{ + "FROM node:18", + "WORKDIR /usr/src/app", + "COPY . .", + } + got := workdirFromDockerfile(lines) + if got != "/usr/src/app" { + t.Errorf("expected /usr/src/app, got %q", got) + } +} + +func TestWorkdirFromDockerfile_LastWins(t *testing.T) { + lines := []string{ + "FROM node:18", + "WORKDIR /first", + "WORKDIR /second", + } + got := workdirFromDockerfile(lines) + if got != "/second" { + t.Errorf("expected /second, got %q", got) + } +} + +func TestWorkdirFromDockerfile_DefaultApp(t *testing.T) { + lines := []string{"FROM node:18", "COPY . ."} + got := workdirFromDockerfile(lines) + if got != "/app" { + t.Errorf("expected default /app, got %q", got) + } +} + +func TestWorkdirFromDockerfile_CaseInsensitive(t *testing.T) { + lines := []string{"FROM node:18", "workdir /myapp"} + got := workdirFromDockerfile(lines) + if got != "/myapp" { + t.Errorf("expected /myapp, got %q", got) + } +} + +// --- helpers --- + +func assertContains(t *testing.T, args []string, val string) { + t.Helper() + for _, a := range args { + if a == val { + return + } + } + t.Errorf("expected %q in args: %v", val, args) +} + +func assertContainsPair(t *testing.T, args []string, key, val string) { + t.Helper() + for i, a := range args { + if a == key && i+1 < len(args) && args[i+1] == val { + return + } + } + t.Errorf("expected %s %s in args: %v", key, val, args) +} diff --git a/internal/config/seeders_test.go b/internal/config/seeders_test.go new file mode 100644 index 0000000..e9da2b1 --- /dev/null +++ b/internal/config/seeders_test.go @@ -0,0 +1,147 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/getkaze/keel/internal/model" +) + +func makeSeederTestStore(t *testing.T) (*SeederStore, string) { + t.Helper() + dir := t.TempDir() + seedersDir := filepath.Join(dir, "data", "seeders") + os.MkdirAll(seedersDir, 0755) + return NewSeederStore(dir), dir +} + +func writeSeeder(t *testing.T, dir string, sd model.Seeder) { + t.Helper() + data, _ := json.MarshalIndent(sd, "", " ") + path := filepath.Join(dir, "data", "seeders", sd.Name+".json") + os.WriteFile(path, data, 0644) +} + +func TestSeederStore_List_Empty(t *testing.T) { + store, _ := makeSeederTestStore(t) + seeders, err := store.List() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(seeders) != 0 { + t.Errorf("expected 0 seeders, got %d", len(seeders)) + } +} + +func TestSeederStore_List_MissingDir(t *testing.T) { + store := &SeederStore{seedersDir: "/tmp/keel-nonexistent-seeders-dir-xyz"} + seeders, err := store.List() + if err != nil { + t.Fatalf("expected no error for missing dir, got: %v", err) + } + if seeders != nil { + t.Errorf("expected nil for missing dir, got %v", seeders) + } +} + +func TestSeederStore_Get_Found(t *testing.T) { + store, dir := makeSeederTestStore(t) + writeSeeder(t, dir, model.Seeder{ + Name: "seed-mysql", + Target: "mysql", + Order: 1, + }) + + sd, err := store.Get("seed-mysql") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sd == nil { + t.Fatal("expected seeder, got nil") + } + if sd.Name != "seed-mysql" { + t.Errorf("expected name 'seed-mysql', got %q", sd.Name) + } + if sd.Target != "mysql" { + t.Errorf("expected target 'mysql', got %q", sd.Target) + } +} + +func TestSeederStore_Get_NotFound(t *testing.T) { + store, _ := makeSeederTestStore(t) + sd, err := store.Get("nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sd != nil { + t.Errorf("expected nil, got %+v", sd) + } +} + +func TestSeederStore_List_IgnoresNonJSON(t *testing.T) { + store, dir := makeSeederTestStore(t) + writeSeeder(t, dir, model.Seeder{Name: "seed-a", Target: "mysql", Order: 1}) + // Write a script file (non-JSON) + os.WriteFile(filepath.Join(dir, "data", "seeders", "init.sql"), []byte("CREATE TABLE..."), 0644) + + seeders, err := store.List() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(seeders) != 1 { + t.Errorf("expected 1 seeder (ignoring .sql), got %d", len(seeders)) + } +} + +func TestSeederStore_List_SortedByOrder(t *testing.T) { + store, dir := makeSeederTestStore(t) + writeSeeder(t, dir, model.Seeder{Name: "seed-b", Target: "mysql", Order: 2}) + writeSeeder(t, dir, model.Seeder{Name: "seed-a", Target: "mysql", Order: 1}) + + seeders, err := store.List() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(seeders) != 2 { + t.Fatalf("expected 2 seeders, got %d", len(seeders)) + } + if seeders[0].Name != "seed-a" { + t.Errorf("expected first seeder 'seed-a' (order 1), got %q", seeders[0].Name) + } +} + +func TestSeederStore_GetRaw(t *testing.T) { + store, dir := makeSeederTestStore(t) + writeSeeder(t, dir, model.Seeder{Name: "seed-raw", Target: "mysql"}) + + raw, err := store.GetRaw("seed-raw") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(raw) == 0 { + t.Fatal("expected non-empty raw data") + } + // Should be valid JSON + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + t.Errorf("raw data is not valid JSON: %v", err) + } +} + +func TestSeederStore_GetRaw_NotFound(t *testing.T) { + store, _ := makeSeederTestStore(t) + _, err := store.GetRaw("nonexistent") + if err == nil { + t.Fatal("expected error for missing seeder") + } +} + +func TestSeederStore_Dir(t *testing.T) { + store := NewSeederStore("/opt/keel") + expected := "/opt/keel/data/seeders" + if store.Dir() != expected { + t.Errorf("expected %q, got %q", expected, store.Dir()) + } +} diff --git a/internal/config/target_test.go b/internal/config/target_test.go new file mode 100644 index 0000000..ecf630b --- /dev/null +++ b/internal/config/target_test.go @@ -0,0 +1,213 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func makeTargetTestDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "data"), 0755) + os.MkdirAll(filepath.Join(dir, "state"), 0755) + return dir +} + +func writeTargetsFile(t *testing.T, dir string, content string) { + t.Helper() + os.WriteFile(filepath.Join(dir, "data", "targets.json"), []byte(content), 0644) +} + +func TestReadTarget_MissingFile(t *testing.T) { + dir := makeTargetTestDir(t) + info, err := ReadTarget(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Mode != "local" || info.Host != "localhost" { + t.Errorf("expected local/localhost default, got mode=%q host=%q", info.Mode, info.Host) + } +} + +func TestReadTarget_LocalTarget(t *testing.T) { + dir := makeTargetTestDir(t) + writeTargetsFile(t, dir, `{ + "targets": { + "local": {"host": "localhost"} + }, + "default": "local" + }`) + + info, err := ReadTarget(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Mode != "local" { + t.Errorf("expected mode 'local', got %q", info.Mode) + } + if info.Host != "localhost" { + t.Errorf("expected host 'localhost', got %q", info.Host) + } +} + +func TestReadTarget_RemoteTarget(t *testing.T) { + dir := makeTargetTestDir(t) + user := "deploy" + writeTargetsFile(t, dir, `{ + "targets": { + "prod": {"host": "10.0.0.1", "ssh_user": "`+user+`", "ssh_key": "~/.ssh/id_ed25519"} + }, + "default": "prod" + }`) + os.WriteFile(filepath.Join(dir, "state", "target"), []byte("prod\n"), 0644) + + info, err := ReadTarget(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Mode != "remote" { + t.Errorf("expected mode 'remote', got %q", info.Mode) + } + if info.User != "deploy" { + t.Errorf("expected user 'deploy', got %q", info.User) + } +} + +func TestReadTargetConfig_DefaultPortBind(t *testing.T) { + dir := makeTargetTestDir(t) + writeTargetsFile(t, dir, `{ + "targets": { + "local": {"host": "localhost"} + } + }`) + os.WriteFile(filepath.Join(dir, "state", "target"), []byte("local\n"), 0644) + + cfg, err := ReadTargetConfig(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.PortBind != "127.0.0.1" { + t.Errorf("expected default port bind '127.0.0.1', got %q", cfg.PortBind) + } +} + +func TestReadTargetConfig_CustomPortBind(t *testing.T) { + dir := makeTargetTestDir(t) + writeTargetsFile(t, dir, `{ + "targets": { + "staging": {"host": "10.0.0.2", "port_bind": "0.0.0.0", "ssh_user": "user"} + } + }`) + os.WriteFile(filepath.Join(dir, "state", "target"), []byte("staging\n"), 0644) + + cfg, err := ReadTargetConfig(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.PortBind != "0.0.0.0" { + t.Errorf("expected port bind '0.0.0.0', got %q", cfg.PortBind) + } +} + +func TestReadTargetConfig_NotFound(t *testing.T) { + dir := makeTargetTestDir(t) + writeTargetsFile(t, dir, `{"targets": {"prod": {"host": "10.0.0.1"}}}`) + os.WriteFile(filepath.Join(dir, "state", "target"), []byte("staging\n"), 0644) + + _, err := ReadTargetConfig(dir) + if err == nil { + t.Fatal("expected error for unknown target") + } +} + +func TestListTargets_MissingFile(t *testing.T) { + dir := makeTargetTestDir(t) + names, err := ListTargets(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 1 || names[0] != "local" { + t.Errorf("expected [local], got %v", names) + } +} + +func TestListTargets_Multiple(t *testing.T) { + dir := makeTargetTestDir(t) + writeTargetsFile(t, dir, `{ + "targets": { + "prod": {"host": "10.0.0.1"}, + "staging": {"host": "10.0.0.2"}, + "local": {"host": "localhost"} + } + }`) + names, err := ListTargets(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 3 { + t.Errorf("expected 3 targets, got %d", len(names)) + } + // Should be sorted + if names[0] != "local" || names[1] != "prod" || names[2] != "staging" { + t.Errorf("expected sorted [local prod staging], got %v", names) + } +} + +func TestSetTarget_ReadBack(t *testing.T) { + dir := makeTargetTestDir(t) + if err := SetTarget(dir, "prod"); err != nil { + t.Fatalf("set target error: %v", err) + } + got := activeTargetName(dir) + if got != "prod" { + t.Errorf("expected 'prod', got %q", got) + } +} + +func TestActiveTargetName_Default(t *testing.T) { + dir := makeTargetTestDir(t) + got := activeTargetName(dir) + if got != "local" { + t.Errorf("expected default 'local', got %q", got) + } +} + +func TestReadTargetConfig_InvalidJSON(t *testing.T) { + dir := makeTargetTestDir(t) + os.WriteFile(filepath.Join(dir, "data", "targets.json"), []byte("{invalid"), 0644) + _, err := ReadTargetConfig(dir) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestReadTargetConfig_SSHJump(t *testing.T) { + dir := makeTargetTestDir(t) + user := "deploy" + data := map[string]interface{}{ + "targets": map[string]interface{}{ + "prod": map[string]interface{}{ + "host": "10.0.0.1", + "ssh_user": user, + "ssh_key": "~/.ssh/key", + "ssh_jump": "bastion.example.com", + }, + }, + } + raw, _ := json.Marshal(data) + os.WriteFile(filepath.Join(dir, "data", "targets.json"), raw, 0644) + os.WriteFile(filepath.Join(dir, "state", "target"), []byte("prod\n"), 0644) + + cfg, err := ReadTargetConfig(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.SSHJump != "bastion.example.com" { + t.Errorf("expected ssh_jump 'bastion.example.com', got %q", cfg.SSHJump) + } + if cfg.SSHKey != "~/.ssh/key" { + t.Errorf("expected ssh_key '~/.ssh/key', got %q", cfg.SSHKey) + } +} diff --git a/internal/docker/executor_test.go b/internal/docker/executor_test.go new file mode 100644 index 0000000..dc8e13a --- /dev/null +++ b/internal/docker/executor_test.go @@ -0,0 +1,335 @@ +package docker + +import ( + "context" + "os/exec" + "strings" + "testing" + + "github.com/getkaze/keel/internal/config" + "github.com/getkaze/keel/internal/model" +) + +// mockRunner implements CmdRunner for testing without Docker. +type mockRunner struct { + portBind string + cmds [][]string // recorded docker args + // cmdFunc optionally overrides DockerCmd behavior. + cmdFunc func(ctx context.Context, args ...string) *exec.Cmd +} + +func (m *mockRunner) DockerCmd(ctx context.Context, args ...string) *exec.Cmd { + m.cmds = append(m.cmds, args) + if m.cmdFunc != nil { + return m.cmdFunc(ctx, args...) + } + return exec.CommandContext(ctx, "echo", "mock") +} + +func (m *mockRunner) SyncFiles(_ context.Context, _ model.Service, _ string) error { return nil } +func (m *mockRunner) GHCRLogin(_ context.Context, _ string) error { return nil } +func (m *mockRunner) PortBind() string { return m.portBind } + +func (m *mockRunner) lastArgs() []string { + if len(m.cmds) == 0 { + return nil + } + return m.cmds[len(m.cmds)-1] +} + +// --- Dispatch tests --- + +func TestDispatch_UnknownCommand(t *testing.T) { + e := newTestExecutor(t, nil) + out := make(chan string, 64) + err := e.dispatch(context.Background(), out, "explode") + if err == nil || !strings.Contains(err.Error(), "unknown command") { + t.Fatalf("expected unknown command error, got %v", err) + } +} + +func TestDispatch_StartRequiresArg(t *testing.T) { + e := newTestExecutor(t, nil) + out := make(chan string, 64) + err := e.dispatch(context.Background(), out, "start") + if err == nil || !strings.Contains(err.Error(), "requires a service name") { + t.Fatalf("expected missing arg error, got %v", err) + } +} + +func TestDispatch_StopRequiresArg(t *testing.T) { + e := newTestExecutor(t, nil) + out := make(chan string, 64) + err := e.dispatch(context.Background(), out, "stop") + if err == nil || !strings.Contains(err.Error(), "requires a service name") { + t.Fatalf("expected missing arg error, got %v", err) + } +} + +func TestDispatch_RestartRequiresArg(t *testing.T) { + e := newTestExecutor(t, nil) + out := make(chan string, 64) + err := e.dispatch(context.Background(), out, "restart") + if err == nil || !strings.Contains(err.Error(), "requires a service name") { + t.Fatalf("expected missing arg error, got %v", err) + } +} + +func TestDispatch_UpdateRequiresArg(t *testing.T) { + e := newTestExecutor(t, nil) + out := make(chan string, 64) + err := e.dispatch(context.Background(), out, "update") + if err == nil || !strings.Contains(err.Error(), "requires a service name") { + t.Fatalf("expected missing arg error, got %v", err) + } +} + +func TestDispatch_StartUnknownService(t *testing.T) { + e := newTestExecutor(t, nil) + out := make(chan string, 64) + err := e.dispatch(context.Background(), out, "start", "nonexistent") + if err == nil || !strings.Contains(err.Error(), "unknown service") { + t.Fatalf("expected unknown service error, got %v", err) + } +} + +// --- Boot arg assembly tests --- + +func TestBoot_DefaultNetwork(t *testing.T) { + svc := model.Service{ + Name: "mysql", + Hostname: "keel-mysql", + Image: "mysql:8", + } + mr := &mockRunner{} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + found := false + for i, a := range args { + if a == "--network" && i+1 < len(args) && args[i+1] == "keel-net" { + found = true + break + } + } + if !found { + t.Errorf("expected --network keel-net in args: %v", args) + } +} + +func TestBoot_CustomNetwork(t *testing.T) { + svc := model.Service{ + Name: "mysql", + Hostname: "keel-mysql", + Image: "mysql:8", + Network: "custom-net", + } + mr := &mockRunner{} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + found := false + for i, a := range args { + if a == "--network" && i+1 < len(args) && args[i+1] == "custom-net" { + found = true + break + } + } + if !found { + t.Errorf("expected --network custom-net in args: %v", args) + } +} + +func TestBoot_PortBinding(t *testing.T) { + svc := model.Service{ + Name: "mysql", + Hostname: "keel-mysql", + Image: "mysql:8", + Ports: model.PortConfig{External: 3306, Internal: 3306}, + } + mr := &mockRunner{portBind: "0.0.0.0"} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + found := false + for i, a := range args { + if a == "-p" && i+1 < len(args) && args[i+1] == "0.0.0.0:3306:3306" { + found = true + break + } + } + if !found { + t.Errorf("expected -p 0.0.0.0:3306:3306 in args: %v", args) + } +} + +func TestBoot_DefaultPortBind(t *testing.T) { + svc := model.Service{ + Name: "mysql", + Hostname: "keel-mysql", + Image: "mysql:8", + Ports: model.PortConfig{External: 3306, Internal: 3306}, + } + mr := &mockRunner{portBind: ""} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + found := false + for i, a := range args { + if a == "-p" && i+1 < len(args) && strings.HasPrefix(args[i+1], "127.0.0.1:") { + found = true + break + } + } + if !found { + t.Errorf("expected -p 127.0.0.1:... in args: %v", args) + } +} + +func TestBoot_KeelLabels(t *testing.T) { + svc := model.Service{ + Name: "mysql", + Hostname: "keel-mysql", + Image: "mysql:8", + } + mr := &mockRunner{} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + hasManaged := false + hasService := false + for i, a := range args { + if a == "--label" && i+1 < len(args) { + if args[i+1] == "keel.managed=true" { + hasManaged = true + } + if args[i+1] == "keel.service=mysql" { + hasService = true + } + } + } + if !hasManaged { + t.Error("missing --label keel.managed=true") + } + if !hasService { + t.Error("missing --label keel.service=mysql") + } +} + +func TestBoot_NoPorts(t *testing.T) { + svc := model.Service{ + Name: "redis", + Hostname: "keel-redis", + Image: "redis:7", + } + mr := &mockRunner{} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + for _, a := range args { + if a == "-p" { + t.Error("should not have -p flag when no ports configured") + } + } +} + +func TestBoot_CommandWithSpaces(t *testing.T) { + svc := model.Service{ + Name: "app", + Hostname: "keel-app", + Image: "myapp:latest", + Command: "npm run dev", + } + mr := &mockRunner{} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + // Should end with: "sh", "-c", "npm run dev" + n := len(args) + if n < 3 || args[n-3] != "sh" || args[n-2] != "-c" || args[n-1] != "npm run dev" { + t.Errorf("expected sh -c 'npm run dev' at end of args: %v", args) + } +} + +func TestBoot_SimpleCommand(t *testing.T) { + svc := model.Service{ + Name: "app", + Hostname: "keel-app", + Image: "myapp:latest", + Command: "server", + } + mr := &mockRunner{} + e := &Executor{Runner: mr, KeelDir: "/tmp/keel"} + out := make(chan string, 64) + _ = e.boot(context.Background(), out, svc) + + args := mr.lastArgs() + if args[len(args)-1] != "server" { + t.Errorf("expected 'server' as last arg, got: %v", args) + } + for _, a := range args { + if a == "sh" { + t.Error("simple command should not use sh -c") + } + } +} + +// --- Volume resolution tests --- + +func TestResolveVolume_NamedVolume(t *testing.T) { + e := &Executor{KeelDir: "/opt/keel"} + result := e.resolveVolume("mydata:/var/lib/data") + if result != "mydata:/var/lib/data" { + t.Errorf("named volume should be unchanged, got %q", result) + } +} + +func TestResolveVolume_AbsolutePath(t *testing.T) { + e := &Executor{KeelDir: "/opt/keel"} + result := e.resolveVolume("/host/path:/container/path") + if result != "/host/path:/container/path" { + t.Errorf("absolute path should be unchanged, got %q", result) + } +} + +func TestResolveVolume_RelativePath(t *testing.T) { + e := &Executor{KeelDir: "/opt/keel"} + result := e.resolveVolume("./data:/var/lib/data") + if result != "/opt/keel/data:/var/lib/data" { + t.Errorf("expected /opt/keel/data:/var/lib/data, got %q", result) + } +} + +// --- emit test --- + +func TestEmit_FullChannel(t *testing.T) { + ch := make(chan string) // unbuffered + // Should not block or panic + emit(ch, "test message") +} + +// --- helper --- + +func newTestExecutor(t *testing.T, runner CmdRunner) *Executor { + t.Helper() + dir := t.TempDir() + store := config.NewServiceStore(dir) + if runner == nil { + runner = &mockRunner{} + } + return NewExecutor(dir, store, runner) +} diff --git a/internal/docker/seeder_test.go b/internal/docker/seeder_test.go new file mode 100644 index 0000000..5352d33 --- /dev/null +++ b/internal/docker/seeder_test.go @@ -0,0 +1,183 @@ +package docker + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// --- State file tests --- + +func TestSeederState_SaveAndLoad(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "data"), 0755) + + se := &SeederExecutor{ + KeelDir: dir, + Cmd: localCmdBuilder{}, + lastStatus: make(map[string]SeederStateEntry), + } + + se.lastStatus["test-seeder"] = SeederStateEntry{ + Status: "success", + RanAt: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + } + se.saveState() + + // Verify file exists + data, err := os.ReadFile(se.stateFile()) + if err != nil { + t.Fatalf("state file not created: %v", err) + } + + var state map[string]SeederStateEntry + if err := json.Unmarshal(data, &state); err != nil { + t.Fatalf("invalid JSON in state file: %v", err) + } + if state["test-seeder"].Status != "success" { + t.Errorf("expected status 'success', got %q", state["test-seeder"].Status) + } + + // Re-load and verify + se2 := &SeederExecutor{ + KeelDir: dir, + Cmd: localCmdBuilder{}, + lastStatus: make(map[string]SeederStateEntry), + } + se2.loadState() + if se2.lastStatus["test-seeder"].Status != "success" { + t.Errorf("expected loaded status 'success', got %q", se2.lastStatus["test-seeder"].Status) + } +} + +func TestSeederState_LoadMissing(t *testing.T) { + dir := t.TempDir() + se := &SeederExecutor{ + KeelDir: dir, + Cmd: localCmdBuilder{}, + lastStatus: make(map[string]SeederStateEntry), + } + se.loadState() // should not error + if len(se.lastStatus) != 0 { + t.Errorf("expected empty status map for missing file, got %d entries", len(se.lastStatus)) + } +} + +func TestSeederState_LoadCorrupted(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "data"), 0755) + os.WriteFile(filepath.Join(dir, "data", "seeder-state.json"), []byte("{invalid"), 0644) + + se := &SeederExecutor{ + KeelDir: dir, + Cmd: localCmdBuilder{}, + lastStatus: make(map[string]SeederStateEntry), + } + se.loadState() + if len(se.lastStatus) != 0 { + t.Errorf("expected empty status for corrupted file, got %d entries", len(se.lastStatus)) + } +} + +func TestGetLastStatus(t *testing.T) { + se := &SeederExecutor{ + lastStatus: map[string]SeederStateEntry{ + "seed-a": {Status: "success"}, + "seed-b": {Status: "error"}, + }, + } + + if got := se.GetLastStatus("seed-a"); got != "success" { + t.Errorf("expected 'success', got %q", got) + } + if got := se.GetLastStatus("seed-b"); got != "error" { + t.Errorf("expected 'error', got %q", got) + } + if got := se.GetLastStatus("unknown"); got != "" { + t.Errorf("expected empty for unknown seeder, got %q", got) + } +} + +func TestGetLastRanAt(t *testing.T) { + now := time.Now() + se := &SeederExecutor{ + lastStatus: map[string]SeederStateEntry{ + "seed-a": {Status: "success", RanAt: now}, + }, + } + + got := se.GetLastRanAt("seed-a") + if got.IsZero() { + t.Fatal("expected non-zero time") + } + if got.Unix() != now.Unix() { + t.Errorf("expected %v, got %v", now, got) + } + + if got := se.GetLastRanAt("unknown"); !got.IsZero() { + t.Errorf("expected zero time for unknown seeder, got %v", got) + } +} + +func TestClearAll(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "data"), 0755) + + se := &SeederExecutor{ + KeelDir: dir, + Cmd: localCmdBuilder{}, + lastStatus: map[string]SeederStateEntry{ + "seed-a": {Status: "success"}, + "seed-b": {Status: "error"}, + }, + } + se.ClearAll() + + if len(se.lastStatus) != 0 { + t.Errorf("expected empty after ClearAll, got %d entries", len(se.lastStatus)) + } + + // State file should exist and be empty map + data, err := os.ReadFile(se.stateFile()) + if err != nil { + t.Fatalf("state file not found: %v", err) + } + var state map[string]SeederStateEntry + json.Unmarshal(data, &state) + if len(state) != 0 { + t.Errorf("state file should be empty map, got %d entries", len(state)) + } +} + +// --- formatDuration tests --- + +func TestFormatDuration_Milliseconds(t *testing.T) { + got := formatDuration(150 * time.Millisecond) + if got != "150ms" { + t.Errorf("expected '150ms', got %q", got) + } +} + +func TestFormatDuration_Seconds(t *testing.T) { + got := formatDuration(2500 * time.Millisecond) + if got != "2.5s" { + t.Errorf("expected '2.5s', got %q", got) + } +} + +func TestFormatDuration_ZeroMs(t *testing.T) { + got := formatDuration(0) + if got != "0ms" { + t.Errorf("expected '0ms', got %q", got) + } +} + +func TestStateFile_Path(t *testing.T) { + se := &SeederExecutor{KeelDir: "/opt/keel"} + expected := "/opt/keel/data/seeder-state.json" + if se.stateFile() != expected { + t.Errorf("expected %q, got %q", expected, se.stateFile()) + } +} diff --git a/internal/docker/status_test.go b/internal/docker/status_test.go index ffb4f63..6a3c46d 100644 --- a/internal/docker/status_test.go +++ b/internal/docker/status_test.go @@ -93,3 +93,45 @@ func TestMatchServiceToContainer_ExplicitHostname(t *testing.T) { t.Errorf("expected myapp-mysql57, got %q", ci.Names) } } + +func TestMergeContainers_Deduplicates(t *testing.T) { + a := []ContainerInfo{ + {ID: "aaa", Names: "/keel-mysql"}, + {ID: "bbb", Names: "/keel-redis"}, + } + b := []ContainerInfo{ + {ID: "bbb", Names: "/keel-redis"}, // duplicate + {ID: "ccc", Names: "/keel-mongo"}, + } + merged := mergeContainers(a, b) + if len(merged) != 3 { + t.Fatalf("expected 3 unique containers, got %d", len(merged)) + } + ids := map[string]bool{} + for _, c := range merged { + ids[c.ID] = true + } + for _, id := range []string{"aaa", "bbb", "ccc"} { + if !ids[id] { + t.Errorf("missing container %s in merged result", id) + } + } +} + +func TestMergeContainers_EmptyLists(t *testing.T) { + merged := mergeContainers(nil, nil) + if len(merged) != 0 { + t.Errorf("expected 0 containers for empty lists, got %d", len(merged)) + } +} + +func TestMergeContainers_OneEmpty(t *testing.T) { + a := []ContainerInfo{{ID: "aaa", Names: "/keel-mysql"}} + merged := mergeContainers(a, nil) + if len(merged) != 1 { + t.Fatalf("expected 1 container, got %d", len(merged)) + } + if merged[0].ID != "aaa" { + t.Errorf("expected id aaa, got %q", merged[0].ID) + } +} diff --git a/internal/handler/services_test.go b/internal/handler/services_test.go new file mode 100644 index 0000000..13d228a --- /dev/null +++ b/internal/handler/services_test.go @@ -0,0 +1,120 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/getkaze/keel/internal/config" + "github.com/getkaze/keel/internal/model" +) + +func makeServiceTestDeps(t *testing.T) (*ServiceDeps, string) { + t.Helper() + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "data", "services"), 0755) + store := config.NewServiceStore(dir) + return &ServiceDeps{ + Services: store, + }, dir +} + +func TestSaveServiceConfig_EmptyBody(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + req := httptest.NewRequest("PUT", "/api/services/test/config", strings.NewReader("")) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "test") + w := httptest.NewRecorder() + deps.saveServiceConfig(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestSaveServiceConfig_InvalidJSON(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + req := httptest.NewRequest("PUT", "/api/services/test/config", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "test") + w := httptest.NewRecorder() + deps.saveServiceConfig(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON, got %d", w.Code) + } +} + +func TestSaveServiceConfig_ValidJSON(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + body := `{"name":"test","hostname":"keel-test","image":"nginx:latest","network":"keel-net","ports":{"internal":80,"external":80}}` + req := httptest.NewRequest("PUT", "/api/services/test/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "test") + w := httptest.NewRecorder() + deps.saveServiceConfig(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSaveServiceConfig_NameMismatch(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + body := `{"name":"other","hostname":"keel-other","image":"nginx:latest","network":"keel-net"}` + req := httptest.NewRequest("PUT", "/api/services/test/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "test") + w := httptest.NewRecorder() + deps.saveServiceConfig(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for name mismatch, got %d", w.Code) + } +} + +func TestSaveServiceConfig_FormData(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + body := `config={"name":"test","hostname":"keel-test","image":"nginx:latest","network":"keel-net","ports":{"internal":80,"external":80}}` + req := httptest.NewRequest("PUT", "/api/services/test/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetPathValue("name", "test") + w := httptest.NewRecorder() + deps.saveServiceConfig(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSaveServiceConfig_BodySizeLimit(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + // Create a body larger than 1 MB + bigBody := strings.Repeat("x", 2<<20) + req := httptest.NewRequest("PUT", "/api/services/test/config", strings.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "test") + w := httptest.NewRecorder() + deps.saveServiceConfig(w, req) + if w.Code == http.StatusOK { + t.Error("expected rejection for oversized body") + } +} + +func TestValidateServiceName_Unknown(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + err := deps.validateServiceName("nonexistent") + if err == nil { + t.Fatal("expected error for unknown service") + } + if !strings.Contains(err.Error(), "unknown service") { + t.Errorf("expected 'unknown service' in error, got %q", err.Error()) + } +} + +func TestValidateServiceName_Found(t *testing.T) { + deps, _ := makeServiceTestDeps(t) + deps.Services.Save(model.Service{Name: "mysql", Image: "mysql:8", Network: "keel-net"}) + err := deps.validateServiceName("mysql") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/handler/terminal_test.go b/internal/handler/terminal_test.go new file mode 100644 index 0000000..cf12e32 --- /dev/null +++ b/internal/handler/terminal_test.go @@ -0,0 +1,80 @@ +package handler + +import ( + "net/http" + "testing" +) + +func makeWSRequest(host, origin string) *http.Request { + r := &http.Request{ + Host: host, + Header: make(http.Header), + } + if origin != "" { + r.Header.Set("Origin", origin) + } + return r +} + +func TestCheckWSOrigin_EmptyOrigin(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "") + if !checkWSOrigin(r) { + t.Error("expected true for empty origin (non-browser client)") + } +} + +func TestCheckWSOrigin_SameHost(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "http://myapp.example.com:8080") + if !checkWSOrigin(r) { + t.Error("expected true for same-host origin") + } +} + +func TestCheckWSOrigin_SameHostDifferentPort(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "http://myapp.example.com:3000") + if !checkWSOrigin(r) { + t.Error("expected true for same host different port") + } +} + +func TestCheckWSOrigin_Localhost(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "http://localhost:3000") + if !checkWSOrigin(r) { + t.Error("expected true for localhost origin") + } +} + +func TestCheckWSOrigin_127001(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "http://127.0.0.1:8080") + if !checkWSOrigin(r) { + t.Error("expected true for 127.0.0.1 origin") + } +} + +func TestCheckWSOrigin_IPv6Loopback(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "http://[::1]:8080") + if !checkWSOrigin(r) { + t.Error("expected true for ::1 origin") + } +} + +func TestCheckWSOrigin_CrossOrigin_Rejected(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "http://evil.example.com") + if checkWSOrigin(r) { + t.Error("expected false for cross-origin request") + } +} + +func TestCheckWSOrigin_InvalidOrigin(t *testing.T) { + r := makeWSRequest("myapp.example.com:8080", "://invalid") + if checkWSOrigin(r) { + t.Error("expected false for invalid origin URL") + } +} + +func TestCheckWSOrigin_HTTPS(t *testing.T) { + r := makeWSRequest("myapp.example.com:443", "https://myapp.example.com") + if !checkWSOrigin(r) { + t.Error("expected true for same host HTTPS") + } +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..a53d7b4 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,75 @@ +package metrics + +import ( + "testing" +) + +func TestReadCPU_ReturnsValidStruct(t *testing.T) { + cpu, err := ReadCPU() + if err != nil { + t.Fatalf("ReadCPU error: %v", err) + } + if cpu.UsagePercent < 0 || cpu.UsagePercent > 100 { + t.Errorf("CPU usage out of range: %f", cpu.UsagePercent) + } +} + +func TestReadMemory_ReturnsValidStruct(t *testing.T) { + mem, err := ReadMemory() + if err != nil { + t.Fatalf("ReadMemory error: %v", err) + } + if mem.TotalBytes == 0 { + t.Error("expected non-zero TotalBytes") + } + if mem.UsagePercent < 0 || mem.UsagePercent > 100 { + t.Errorf("memory usage out of range: %f", mem.UsagePercent) + } + if mem.UsedBytes > mem.TotalBytes { + t.Errorf("used (%d) > total (%d)", mem.UsedBytes, mem.TotalBytes) + } +} + +func TestReadDisk_ReturnsValidStruct(t *testing.T) { + disk, err := ReadDisk() + if err != nil { + t.Fatalf("ReadDisk error: %v", err) + } + if disk.TotalBytes == 0 { + t.Error("expected non-zero TotalBytes") + } + if disk.UsagePercent < 0 || disk.UsagePercent > 100 { + t.Errorf("disk usage out of range: %f", disk.UsagePercent) + } +} + +func TestReadLoadAvg_ReturnsValidStruct(t *testing.T) { + la, err := ReadLoadAvg() + if err != nil { + t.Fatalf("ReadLoadAvg error: %v", err) + } + if la.Load1 < 0 { + t.Errorf("Load1 should be non-negative, got %f", la.Load1) + } +} + +func TestReadUptime_ReturnsValidStruct(t *testing.T) { + up, err := ReadUptime() + if err != nil { + t.Fatalf("ReadUptime error: %v", err) + } + if up.UptimeSeconds <= 0 { + t.Errorf("expected positive uptime, got %f", up.UptimeSeconds) + } +} + +func TestReadCPU_UsageIsReasonable(t *testing.T) { + cpu, err := ReadCPU() + if err != nil { + t.Fatalf("ReadCPU error: %v", err) + } + // Just verify it's a valid percentage + if cpu.UsagePercent < 0 || cpu.UsagePercent > 100 { + t.Errorf("CPU usage out of range [0-100]: %f", cpu.UsagePercent) + } +} diff --git a/internal/metrics/remote_test.go b/internal/metrics/remote_test.go new file mode 100644 index 0000000..f7519de --- /dev/null +++ b/internal/metrics/remote_test.go @@ -0,0 +1,150 @@ +package metrics + +import ( + "strings" + "testing" +) + +const sampleRemoteOutput = `cpu 7894 123 4567 890123 456 0 78 0 0 0 +cpu 7900 123 4570 890200 456 0 80 0 0 0 +MemTotal: 16384000 kB +MemFree: 2048000 kB +MemAvailable: 8192000 kB +Buffers: 512000 kB +---LOADAVG--- +1.25 0.85 0.50 2/345 12345 +---UPTIME--- +86400.50 172800.00 +---DISK--- +/dev/sda1 500000000000 200000000000 250000000000 45% / +` + +func TestParseRemoteMetrics_CPU(t *testing.T) { + cpu, _, _, _, _, err := parseRemoteMetrics(sampleRemoteOutput) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cpu.UsagePercent < 0 || cpu.UsagePercent > 100 { + t.Errorf("CPU usage out of range: %f", cpu.UsagePercent) + } +} + +func TestParseRemoteMetrics_Memory(t *testing.T) { + _, mem, _, _, _, err := parseRemoteMetrics(sampleRemoteOutput) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedTotal := uint64(16384000) * 1024 + if mem.TotalBytes != expectedTotal { + t.Errorf("expected total %d, got %d", expectedTotal, mem.TotalBytes) + } + expectedAvail := uint64(8192000) * 1024 + if mem.AvailableBytes != expectedAvail { + t.Errorf("expected available %d, got %d", expectedAvail, mem.AvailableBytes) + } + if mem.UsedBytes != mem.TotalBytes-mem.AvailableBytes { + t.Errorf("used bytes mismatch: %d != %d - %d", mem.UsedBytes, mem.TotalBytes, mem.AvailableBytes) + } + if mem.UsagePercent <= 0 || mem.UsagePercent >= 100 { + t.Errorf("memory usage out of range: %f", mem.UsagePercent) + } +} + +func TestParseRemoteMetrics_LoadAvg(t *testing.T) { + _, _, _, la, _, err := parseRemoteMetrics(sampleRemoteOutput) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if la.Load1 != 1.25 { + t.Errorf("expected Load1=1.25, got %f", la.Load1) + } + if la.Load5 != 0.85 { + t.Errorf("expected Load5=0.85, got %f", la.Load5) + } + if la.Load15 != 0.50 { + t.Errorf("expected Load15=0.50, got %f", la.Load15) + } +} + +func TestParseRemoteMetrics_Uptime(t *testing.T) { + _, _, _, _, up, err := parseRemoteMetrics(sampleRemoteOutput) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if up.UptimeSeconds != 86400.50 { + t.Errorf("expected uptime 86400.50, got %f", up.UptimeSeconds) + } +} + +func TestParseRemoteMetrics_Disk(t *testing.T) { + _, _, disk, _, _, err := parseRemoteMetrics(sampleRemoteOutput) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if disk.TotalBytes != 500000000000 { + t.Errorf("expected total 500000000000, got %d", disk.TotalBytes) + } + if disk.UsedBytes != 200000000000 { + t.Errorf("expected used 200000000000, got %d", disk.UsedBytes) + } + if disk.AvailableBytes != 250000000000 { + t.Errorf("expected available 250000000000, got %d", disk.AvailableBytes) + } + if disk.UsagePercent <= 0 { + t.Errorf("expected positive disk usage, got %f", disk.UsagePercent) + } +} + +func TestParseRemoteMetrics_TooShort(t *testing.T) { + _, _, _, _, _, err := parseRemoteMetrics("short") + if err == nil { + t.Error("expected error for short output") + } +} + +func TestParseCPULine_Valid(t *testing.T) { + idle, total := parseCPULine("cpu 7894 123 4567 890123 456 0 78 0 0 0") + if idle != 890123 { + t.Errorf("expected idle=890123, got %d", idle) + } + if total == 0 { + t.Error("expected non-zero total") + } +} + +func TestParseCPULine_TooShort(t *testing.T) { + idle, total := parseCPULine("cpu 1 2") + if idle != 0 || total != 0 { + t.Errorf("expected 0,0 for short line, got %d,%d", idle, total) + } +} + +func TestParseRemoteMetrics_EmptySections(t *testing.T) { + // Only CPU lines, no sections + raw := strings.Join([]string{ + "cpu 100 0 50 800 10 0 5 0 0 0", + "cpu 110 0 55 810 10 0 6 0 0 0", + "MemTotal: 8192000 kB", + "MemAvailable: 4096000 kB", + "---LOADAVG---", + "---UPTIME---", + "---DISK---", + }, "\n") + cpu, mem, _, la, up, err := parseRemoteMetrics(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cpu.UsagePercent < 0 { + t.Error("expected valid CPU usage") + } + if mem.TotalBytes == 0 { + t.Error("expected non-zero memory") + } + // Empty sections should yield zero values + if la.Load1 != 0 { + t.Errorf("expected Load1=0 for empty section, got %f", la.Load1) + } + if up.UptimeSeconds != 0 { + t.Errorf("expected uptime=0 for empty section, got %f", up.UptimeSeconds) + } +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..fef81a2 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,94 @@ +package updater + +import ( + "testing" +) + +func TestIsNewer_SimpleNewer(t *testing.T) { + if !IsNewer("0.3.0", "0.2.0") { + t.Error("0.3.0 should be newer than 0.2.0") + } +} + +func TestIsNewer_MajorVersion(t *testing.T) { + if !IsNewer("1.0.0", "0.99.99") { + t.Error("1.0.0 should be newer than 0.99.99") + } +} + +func TestIsNewer_TenVsNine(t *testing.T) { + if !IsNewer("0.10.0", "0.9.0") { + t.Error("0.10.0 should be newer than 0.9.0 (semantic, not string)") + } +} + +func TestIsNewer_Equal(t *testing.T) { + if IsNewer("0.3.0", "0.3.0") { + t.Error("equal versions should not be 'newer'") + } +} + +func TestIsNewer_OlderVersion(t *testing.T) { + if IsNewer("0.1.0", "0.2.0") { + t.Error("0.1.0 should not be newer than 0.2.0") + } +} + +func TestIsNewer_PatchVersion(t *testing.T) { + if !IsNewer("0.3.1", "0.3.0") { + t.Error("0.3.1 should be newer than 0.3.0") + } +} + +func TestIsNewer_WithVPrefix(t *testing.T) { + if !IsNewer("v0.3.0", "v0.2.0") { + t.Error("v0.3.0 should be newer than v0.2.0 (v prefix stripped)") + } +} + +func TestIsNewer_InvalidFallback(t *testing.T) { + // Non-semver strings fall back to string comparison (not equal = newer) + if !IsNewer("dev", "0.3.0") { + t.Error("non-parseable versions with different strings should return true (fallback)") + } +} + +func TestIsNewer_BothInvalid_Equal(t *testing.T) { + if IsNewer("dev", "dev") { + t.Error("same non-parseable strings should return false") + } +} + +func TestParseSemver_Valid(t *testing.T) { + parts, ok := parseSemver("1.2.3") + if !ok { + t.Fatal("expected ok=true for valid semver") + } + if parts != [3]int{1, 2, 3} { + t.Errorf("expected [1,2,3], got %v", parts) + } +} + +func TestParseSemver_WithV(t *testing.T) { + parts, ok := parseSemver("v0.10.5") + if !ok { + t.Fatal("expected ok=true for v-prefixed semver") + } + if parts != [3]int{0, 10, 5} { + t.Errorf("expected [0,10,5], got %v", parts) + } +} + +func TestParseSemver_Invalid(t *testing.T) { + _, ok := parseSemver("dev") + if ok { + t.Error("expected ok=false for non-semver string") + } +} + +func TestParseSemver_TwoParts(t *testing.T) { + _, ok := parseSemver("1.2") + if ok { + t.Error("expected ok=false for two-part version") + } +} From de7ddc60e942a2456b0c05f6c4ac333e215ecadb Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:47:14 -0300 Subject: [PATCH 14/25] feat: add label-based container detection with network fallback --- internal/docker/status.go | 40 ++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/internal/docker/status.go b/internal/docker/status.go index 3b84681..3f2f7d3 100644 --- a/internal/docker/status.go +++ b/internal/docker/status.go @@ -39,7 +39,7 @@ func NewStatusPoller() *StatusPoller { } } -// ListContainers returns all containers on keel-net, using cache if fresh. +// ListContainers returns all Keel-managed containers (by label or keel-net network), using cache if fresh. func (p *StatusPoller) ListContainers(ctx context.Context) ([]ContainerInfo, error) { p.mu.RLock() if time.Now().Before(p.expiry) { @@ -71,10 +71,25 @@ func fetchContainers(ctx context.Context) ([]ContainerInfo, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "docker", "ps", "-a", - "--filter", "network=keel-net", - "--format", "json", - ) + // Query by label (new containers) and by network (legacy containers). + byLabel, err := dockerPS(ctx, "--filter", "label=keel.managed=true") + if err != nil { + return nil, err + } + byNetwork, err := dockerPS(ctx, "--filter", "network=keel-net") + if err != nil { + return nil, err + } + + return mergeContainers(byLabel, byNetwork), nil +} + +// dockerPS runs "docker ps -a --format json" with extra filter args. +func dockerPS(ctx context.Context, filters ...string) ([]ContainerInfo, error) { + args := []string{"ps", "-a", "--format", "json"} + args = append(args, filters...) + + cmd := exec.CommandContext(ctx, "docker", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -105,6 +120,21 @@ func fetchContainers(ctx context.Context) ([]ContainerInfo, error) { return containers, nil } +// mergeContainers deduplicates containers from multiple sources by ID. +func mergeContainers(lists ...[]ContainerInfo) []ContainerInfo { + seen := make(map[string]bool) + var result []ContainerInfo + for _, list := range lists { + for _, c := range list { + if !seen[c.ID] { + seen[c.ID] = true + result = append(result, c) + } + } + } + return result +} + // MatchServiceToContainer finds a container matching a service name or hostname. // Priority: explicit hostname > keel-{name} > {name}. func MatchServiceToContainer(serviceName, serviceHostname string, containers []ContainerInfo) *ContainerInfo { From 419bbd2d1936cd23a640c494e1e31ac9a183f5ba Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:52:25 -0300 Subject: [PATCH 15/25] feat: adds dual donate options for Brazilian and international supporters --- web/static/app.js | 6 +++++ web/static/style.css | 32 ++++++++++++++++++++++++ web/templates/layout.html | 26 +++++++++++-------- web/templates/partials/settings.html | 37 +++++++++++++++++++--------- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/web/static/app.js b/web/static/app.js index 34fc347..b9981ef 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -268,6 +268,12 @@ function setActiveNav(page) { } document.addEventListener('click', function(e) { + // Close donate dropdown when clicking outside + var dropdown = document.querySelector('.donate-dropdown.open'); + if (dropdown && !dropdown.contains(e.target)) { + dropdown.classList.remove('open'); + } + var navItem = e.target.closest('[data-page]'); if (!navItem) return; setActiveNav(navItem.getAttribute('data-page')); diff --git a/web/static/style.css b/web/static/style.css index 49b8688..1df2ac2 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -226,6 +226,38 @@ a:hover { opacity: 0.85; } opacity: 1; } +.donate-dropdown .donate-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 0; + min-width: 160px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,.3); +} + +.donate-dropdown.open .donate-menu { display: block; } + +.donate-menu a { + display: block; + padding: 6px 12px; + color: var(--muted); + text-decoration: none; + font-size: 11px; + font-family: var(--mono); + letter-spacing: .03em; +} + +.donate-menu a:hover { + color: var(--text); + background: var(--surface2); +} + .topbar-right { margin-left: auto; display: flex; diff --git a/web/templates/layout.html b/web/templates/layout.html index 3a4ca71..ddac18b 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -37,16 +37,22 @@
- - - - - - - - - buy me a coffee - +
diff --git a/web/templates/partials/settings.html b/web/templates/partials/settings.html index 6d85033..6dda781 100644 --- a/web/templates/partials/settings.html +++ b/web/templates/partials/settings.html @@ -27,19 +27,32 @@ support the project

- If Keel is useful to you, consider buying me a coffee to support development. + If Keel is useful to you, consider donating to support development.

- - - - - - - - - Buy me a coffee - +
From 9d90895f08258b37efa7c1e7558f0496748ce961 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 08:54:44 -0300 Subject: [PATCH 16/25] docs: adds v0.3 changelog --- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a7f4b..2867c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,51 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [0.3] — 2026-03-18 + +### Added + +- CmdRunner abstraction — local and remote Docker execution behind a unified interface (@mateusmetzker) +- LocalRunner and ReloadableRunner with hot-swap support for target config changes (@mateusmetzker) +- SSH utilities extracted into `internal/ssh` package, shared across all SSH consumers (@mateusmetzker) +- TunnelMonitor with automatic reconnection, exponential backoff, and health checks (@mateusmetzker) +- SSE endpoint for tunnel status (`GET /api/tunnel/status`) with live status dot in the topbar (@mateusmetzker) +- Label-based container detection (`keel.managed=true`) with network fallback for backward compatibility (@mateusmetzker) +- Semver comparison in updater — correctly handles `0.10.0 > 0.9.0` (@mateusmetzker) +- Cross-device-safe updater — temp file created in same directory as binary (@mateusmetzker) +- IP validation for `keel hosts setup` — rejects invalid addresses before modifying `/etc/hosts` (@mateusmetzker) +- Body size limits: 64 KB for service creation, 1 MB for config save (@mateusmetzker) +- CI workflow with `go test -race` on push/PR; test job added as prerequisite in release workflow (@mateusmetzker) +- 155 unit tests across all packages (@mateusmetzker) +- Dual donate options: Stripe for Brazilian supporters, Buy Me a Coffee for international (@mateusmetzker) + +### Changed + +- Migrated local metrics (CPU, memory, disk, load average, uptime) from manual `/proc` parsing to gopsutil v4 (@mateusmetzker) +- `start-all`, `stop-all`, and seeder run endpoints changed from GET to POST (@mateusmetzker) +- SSE error events renamed from `error` to `app-error` to avoid conflicts with `EventSource.onerror` (@mateusmetzker) +- SSE streams now support POST via `fetch + ReadableStream` for mutation endpoints (@mateusmetzker) +- Template rendering buffered — errors return clean HTTP 500 instead of partial HTML (@mateusmetzker) +- Health check handler reuses a shared `http.Client` instead of creating one per request (@mateusmetzker) +- Remote metrics cached for 10 seconds with background refresh — no more blocking SSH calls per HTTP request (@mateusmetzker) +- Log navigation uses `htmx:afterSettle` instead of `setTimeout` for reliable service pre-selection (@mateusmetzker) +- GHCR login now pipes PAT over stdin instead of shell interpolation (@mateusmetzker) +- SSH options hardened: `StrictHostKeyChecking=accept-new` replaces `StrictHostKeyChecking=no` (@mateusmetzker) +- WebSocket origin check validates same-host/localhost instead of accepting all origins (@mateusmetzker) + +### Fixed + +- Terminal deadlock: `Session.Close` is now idempotent via `sync.Once`; close called before `wg.Wait` (@mateusmetzker) +- Terminal ANSI clear race condition: moved from server-side PTY write to client-side `term.clear()` on WebSocket open (@mateusmetzker) +- Update toast now shows error details when pull fails, instead of always showing "UPDATE COMPLETE" (@mateusmetzker) +- Log viewer path traversal: file paths validated against configured log source directories (@mateusmetzker) +- Config editor: `saveServiceConfig` uses `io.ReadAll` + `json.Valid` instead of broken `fmt.Fscan` (@mateusmetzker) +- Destructive update prevention: failed `docker pull` no longer removes the running container (@mateusmetzker) +- Seeder card CSS selector corrected from `.seeder-card` to `.seeder-item` (@mateusmetzker) +- Executor `dockerStream` gains idle timeout and non-blocking channel sends with log-on-drop (@mateusmetzker) + +--- + ## [0.2] — 2026-03-15 ### Added @@ -110,7 +155,8 @@ Initial public release (@mateusmetzker). - Data directory: `/var/lib/keel` (Linux) or `~/.keel` (macOS) - Install script: `curl -fsSL https://getkaze.dev/keel/install.sh | sudo bash` -[Unreleased]: https://github.com/getkaze/keel/compare/v0.2...HEAD +[Unreleased]: https://github.com/getkaze/keel/compare/v0.3...HEAD +[0.3]: https://github.com/getkaze/keel/compare/v0.2...v0.3 [0.2]: https://github.com/getkaze/keel/compare/v0.1.1...v0.2 [0.1.1]: https://github.com/getkaze/keel/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/getkaze/keel/releases/tag/v0.1.0 From 49285b60215fc6ca7e08c4053817568e3eb424bc Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 09:42:41 -0300 Subject: [PATCH 17/25] feat: adds self-update via dashboard with SSE progress and restart --- internal/handler/version.go | 113 ++++++++++++++++++++- internal/server/routes.go | 1 + internal/updater/updater.go | 144 ++++++++++++++++++++++---- web/static/app.js | 196 ++++++++++++++++++++++++++++++++++++ web/static/style.css | 163 +++++++++++++++++++++++++++--- web/templates/layout.html | 42 ++++++-- 6 files changed, 613 insertions(+), 46 deletions(-) diff --git a/internal/handler/version.go b/internal/handler/version.go index 5ce69ba..7c68418 100644 --- a/internal/handler/version.go +++ b/internal/handler/version.go @@ -2,7 +2,14 @@ package handler import ( "encoding/json" + "fmt" + "log" "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" "github.com/getkaze/keel/internal/updater" ) @@ -25,6 +32,110 @@ func (h *VersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + resp := map[string]any{ + "current": result.Current, + "latest": result.Latest, + "update_url": result.UpdateURL, + "available": result.Available, + } + + // Fetch release notes for the latest version + if result.Available { + if notes, err := updater.FetchReleaseNotes(result.Latest); err == nil { + resp["changelog"] = notes + } + } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) + json.NewEncoder(w).Encode(resp) +} + +// UpdateHandler performs a self-update via SSE, then restarts the process. +type UpdateHandler struct { + Version string +} + +func (h *UpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + send := func(msg string) { + fmt.Fprintf(w, "data: %s\n\n", msg) + flusher.Flush() + } + + // 1. Check latest version + send("Checking for updates...") + result, err := updater.Check(h.Version) + if err != nil { + fmt.Fprintf(w, "event: app-error\ndata: Failed to check version: %s\n\n", err) + flusher.Flush() + return + } + + if !result.Available { + fmt.Fprintf(w, "event: app-error\ndata: Already on latest version %s\n\n", h.Version) + flusher.Flush() + return + } + + send(fmt.Sprintf("New version available: %s → %s", result.Current, result.Latest)) + + // 2. Download + send(fmt.Sprintf("Downloading %s...", result.Latest)) + tmpPath, err := updater.Download(result.Latest) + if err != nil { + msg := fmt.Sprintf("Download failed: %s", err) + if strings.Contains(err.Error(), "permission denied") { + msg = "Permission denied — restart keel with sudo to update" + } + fmt.Fprintf(w, "event: app-error\ndata: %s\n\n", msg) + flusher.Flush() + return + } + + // 3. Replace binary + send("Replacing binary...") + if err := updater.Replace(tmpPath); err != nil { + msg := fmt.Sprintf("Replace failed: %s", err) + if strings.Contains(err.Error(), "permission denied") { + msg = "Permission denied — restart keel with sudo to update" + } + fmt.Fprintf(w, "event: app-error\ndata: %s\n\n", msg) + flusher.Flush() + return + } + + send(fmt.Sprintf("Updated to %s successfully!", result.Latest)) + fmt.Fprintf(w, "event: done\ndata: Updated to %s — restarting...\n\n", result.Latest) + flusher.Flush() + + // 4. Restart: re-exec the new binary after a short delay + go func() { + time.Sleep(500 * time.Millisecond) + exe, err := os.Executable() + if err != nil { + log.Printf("update: cannot find executable for restart: %v", err) + os.Exit(0) + } + + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + cmd := exec.Command(exe, os.Args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Start(); err != nil { + log.Printf("update: restart failed: %v", err) + } + } + + os.Exit(0) + }() } diff --git a/internal/server/routes.go b/internal/server/routes.go index 6df2d7b..d771de8 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -156,6 +156,7 @@ func registerRoutes(mux *http.ServeMux, cfg Config) { } mux.Handle("GET /api/metrics", metricsHandler) mux.Handle("GET /api/version", &handler.VersionHandler{Version: cfg.Version}) + mux.Handle("POST /api/update", &handler.UpdateHandler{Version: cfg.Version}) // Page partials (HTMX) handler.RegisterPageRoutes(mux, &handler.PageDeps{ diff --git a/internal/updater/updater.go b/internal/updater/updater.go index de70afa..8bcfe53 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -1,6 +1,7 @@ package updater import ( + "encoding/json" "fmt" "io" "net/http" @@ -12,7 +13,7 @@ import ( "time" ) -const ReleasesBase = "https://releases.getkaze.dev" +const ReleasesBase = "https://github.com/getkaze/keel/releases" // CheckResult holds the result of a version check. type CheckResult struct { @@ -22,25 +23,27 @@ type CheckResult struct { Available bool `json:"available"` } -// Check fetches the latest version and compares with current. +// Check fetches the latest version via GitHub releases redirect and compares with current. func Check(current string) (*CheckResult, error) { - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(ReleasesBase + "/version") + client := &http.Client{ + Timeout: 5 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(ReleasesBase + "/latest") if err != nil { return nil, fmt.Errorf("fetch version: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("fetch version: status %d", resp.StatusCode) - } + resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read version: %w", err) + loc := resp.Header.Get("Location") + if loc == "" { + return nil, fmt.Errorf("fetch version: no redirect from /latest") } - latest := strings.TrimSpace(string(body)) + parts := strings.Split(loc, "/") + latest := parts[len(parts)-1] return &CheckResult{ Current: current, @@ -86,8 +89,8 @@ func parseSemver(v string) ([3]int, bool) { } // Download fetches the latest binary to a temp file and returns its path. -// The temp file is created in the same directory as the running binary -// to avoid cross-device rename errors when /tmp is on a different filesystem. +// Tries the binary's directory first (enables atomic rename); falls back to +// os.TempDir() when the binary directory is not writable (e.g. /usr/local/bin). func Download(version string) (string, error) { url := downloadURL(version) @@ -106,11 +109,14 @@ func Download(version string) (string, error) { if err != nil { return "", fmt.Errorf("find executable: %w", err) } - targetDir := filepath.Dir(exe) - tmp, err := os.CreateTemp(targetDir, "keel-update-*") + // Try binary dir first, fall back to system temp. + tmp, err := os.CreateTemp(filepath.Dir(exe), "keel-update-*") if err != nil { - return "", fmt.Errorf("create temp: %w", err) + tmp, err = os.CreateTemp("", "keel-update-*") + if err != nil { + return "", fmt.Errorf("create temp: %w", err) + } } if _, err := io.Copy(tmp, resp.Body); err != nil { @@ -128,22 +134,116 @@ func Download(version string) (string, error) { return tmp.Name(), nil } -// Replace atomically replaces the current binary with the downloaded one. +// Replace replaces the current binary with the downloaded one. +// Uses atomic os.Rename when possible (same filesystem); falls back to +// copy when rename fails (e.g. cross-device /tmp → /usr/local/bin). func Replace(tmpPath string) error { exe, err := os.Executable() if err != nil { return fmt.Errorf("find executable: %w", err) } - if err := os.Rename(tmpPath, exe); err != nil { - return fmt.Errorf("replace binary: %w", err) + // Try atomic rename first. + if err := os.Rename(tmpPath, exe); err == nil { + return nil + } + + // Fallback: copy and remove. + src, err := os.Open(tmpPath) + if err != nil { + return fmt.Errorf("open update: %w", err) + } + defer src.Close() + + dst, err := os.OpenFile(exe, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("create binary: %w", err) + } + + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + return fmt.Errorf("write binary: %w", err) } + if err := dst.Close(); err != nil { + return fmt.Errorf("close binary: %w", err) + } + + os.Remove(tmpPath) return nil } +// FetchReleaseNotes returns the release body (markdown) for a given version tag. +func FetchReleaseNotes(version string) (string, error) { + url := fmt.Sprintf("https://api.github.com/repos/getkaze/keel/releases/tags/%s", version) + client := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch release notes: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetch release notes: status %d", resp.StatusCode) + } + + var release struct { + Body string `json:"body"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("parse release notes: %w", err) + } + + return stripBoilerplate(release.Body), nil +} + +// stripBoilerplate removes Installation and Manual download sections, +// keeping only the "what's new" content. +func stripBoilerplate(body string) string { + lines := strings.Split(body, "\n") + var result []string + + for i := 0; i < len(lines); i++ { + lower := strings.ToLower(strings.TrimSpace(lines[i])) + + if strings.HasPrefix(lower, "## installation") || + strings.HasPrefix(lower, "### installation") || + strings.HasPrefix(lower, "## manual download") || + strings.HasPrefix(lower, "### manual download") { + break + } + + if lower == "---" || lower == "***" || lower == "___" { + next := "" + for j := i + 1; j < len(lines); j++ { + if strings.TrimSpace(lines[j]) != "" { + next = strings.ToLower(strings.TrimSpace(lines[j])) + break + } + } + if strings.HasPrefix(next, "## installation") || + strings.HasPrefix(next, "### installation") || + strings.HasPrefix(next, "## manual download") || + strings.HasPrefix(next, "### manual download") { + break + } + } + + result = append(result, lines[i]) + } + + return strings.TrimSpace(strings.Join(result, "\n")) +} + func downloadURL(version string) string { osName := runtime.GOOS arch := runtime.GOARCH - return fmt.Sprintf("%s/%s/keel-%s-%s", ReleasesBase, version, osName, arch) + return fmt.Sprintf("%s/download/%s/keel-%s-%s", ReleasesBase, version, osName, arch) } diff --git a/web/static/app.js b/web/static/app.js index b9981ef..3a7d8c3 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -7,7 +7,10 @@ if ('serviceWorker' in navigator) { } // Version check: show update indicator if new version available +var _versionData = null; + fetch('/api/version').then(function(r) { return r.json(); }).then(function(data) { + _versionData = data; if (data.available) { var el = document.getElementById('update-indicator'); var ver = document.getElementById('update-version'); @@ -18,6 +21,199 @@ fetch('/api/version').then(function(r) { return r.json(); }).then(function(data) } }).catch(function() {}); +// Update modal +function openUpdateModal() { + var modal = document.getElementById('update-modal'); + if (!modal || !_versionData) return; + + document.getElementById('update-modal-current').textContent = _versionData.current; + document.getElementById('update-modal-latest').textContent = _versionData.latest; + + var changelogEl = document.getElementById('update-modal-changelog'); + if (_versionData.changelog) { + changelogEl.innerHTML = renderMarkdown(_versionData.changelog); + } else { + changelogEl.innerHTML = '

No release notes available.

'; + } + + document.getElementById('update-modal-progress').style.display = 'none'; + document.getElementById('update-modal-progress').innerHTML = ''; + document.getElementById('update-modal-btn').disabled = false; + document.getElementById('update-modal-btn').style.display = ''; + document.getElementById('update-modal-cancel').style.display = ''; + + modal.showModal(); +} + +function performUpdate() { + var btn = document.getElementById('update-modal-btn'); + var progress = document.getElementById('update-modal-progress'); + var changelog = document.getElementById('update-modal-changelog'); + var cancelBtn = document.getElementById('update-modal-cancel'); + + btn.disabled = true; + btn.innerHTML = ' Updating...'; + cancelBtn.style.display = 'none'; + changelog.style.display = 'none'; + progress.style.display = ''; + + fetch('/api/update', { method: 'POST' }).then(function(response) { + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + var sseEventType = ''; + + function processLine(line) { + if (line.indexOf('event: ') === 0) { + sseEventType = line.substring(7).trim(); + return; + } + if (line.indexOf('data: ') !== 0) { + if (line === '') sseEventType = ''; + return; + } + var data = line.substring(6); + var eventType = sseEventType; + sseEventType = ''; + + var el = document.createElement('div'); + + if (eventType === 'done') { + el.className = 'text-success font-semibold'; + el.textContent = data; + progress.appendChild(el); + btn.style.display = 'none'; + + var reloadLine = document.createElement('div'); + reloadLine.className = 'text-muted mt-1'; + reloadLine.textContent = 'Restarting keel... reloading in a few seconds.'; + progress.appendChild(reloadLine); + setTimeout(function() { waitForRestart(); }, 2000); + } else if (eventType === 'app-error') { + el.className = 'text-error font-semibold'; + el.textContent = data; + progress.appendChild(el); + btn.disabled = false; + btn.innerHTML = 'Retry'; + cancelBtn.style.display = ''; + } else { + el.textContent = data; + progress.appendChild(el); + } + progress.scrollTop = progress.scrollHeight; + } + + function read() { + reader.read().then(function(result) { + if (result.done) return; + buffer += decoder.decode(result.value, { stream: true }); + var lines = buffer.split('\n'); + buffer = lines.pop(); + for (var i = 0; i < lines.length; i++) { + processLine(lines[i]); + } + read(); + }); + } + read(); + }).catch(function(err) { + var line = document.createElement('div'); + line.className = 'text-error font-semibold'; + line.textContent = 'Connection failed: ' + err.message; + progress.appendChild(line); + btn.disabled = false; + btn.innerHTML = 'Retry'; + cancelBtn.style.display = ''; + }); +} + +function waitForRestart() { + var attempts = 0; + var maxAttempts = 30; + function tryReload() { + attempts++; + fetch('/api/health').then(function(r) { + if (r.ok) { + window.location.reload(); + } else { + retry(); + } + }).catch(function() { + retry(); + }); + } + function retry() { + if (attempts < maxAttempts) { + setTimeout(tryReload, 1000); + } else { + var progress = document.getElementById('update-modal-progress'); + if (progress) { + var el = document.createElement('div'); + el.className = 'text-warning'; + el.textContent = 'Could not reconnect. Please reload the page manually.'; + progress.appendChild(el); + } + } + } + tryReload(); +} + +// Changelog markdown renderer with category icons +var changelogIcons = { + "added": '', + "changed": '', + "fixed": '', + "removed": '', + "what's new": '' +}; + +function renderMarkdown(text) { + var lines = text.split('\n'); + var html = ''; + var inList = false; + var inSection = false; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + + if (/^#{2,3}\s+/.test(line)) { + if (inList) { html += ''; inList = false; } + if (inSection) { html += '
'; inSection = false; } + + var heading = line.replace(/^#{2,3}\s+/, '').trim(); + var key = heading.toLowerCase(); + var icon = changelogIcons[key] || ''; + + html += '
'; + html += '

' + icon + escapeHtml(heading) + '

'; + inSection = true; + } else if (/^[-*]\s+/.test(line)) { + if (!inList) { html += '
    '; inList = true; } + var content = line.replace(/^[-*]\s+/, ''); + var rendered = escapeHtml(content).replace(/\*\*(.+?)\*\*/g, '$1'); + html += '
  • ' + rendered + '
  • '; + } else if (line.trim() === '') { + if (inList) { html += '
'; inList = false; } + } else if (line.trim()) { + if (inList) { html += ''; inList = false; } + html += '

' + escapeHtml(line) + '

'; + } + } + if (inList) html += ''; + if (inSection) html += '
'; + + if (!html.trim()) { + html = '

No release notes available.

'; + } + return html; +} + +function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + // AnsiUp instance for ANSI color rendering let ansiUp = null; diff --git a/web/static/style.css b/web/static/style.css index 1df2ac2..e6ba7c0 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -2157,27 +2157,33 @@ progress.bar-warning::-moz-progress-bar { background: var(--yellow); } progress.bar-error::-moz-progress-bar { background: var(--red); } /* ══════════════════════════════════════════ - 23. UPDATE BADGE + 23. UPDATE PILL ══════════════════════════════════════════ */ -.update-badge { - display: flex; +.update-pill { + display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 7px; - background: var(--yellow-bg); - border: 1px solid var(--yellow-bdr); - color: var(--yellow); - font-family: var(--sans); - font-size: 12px; - letter-spacing: .04em; - text-decoration: none; + gap: 5px; + padding: 3px 10px 3px 8px; + background: linear-gradient(135deg, var(--accent), #7c3aed); + border: none; + color: #fff; + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + letter-spacing: .03em; cursor: pointer; - border-radius: 4px; - animation: pulse 2s ease-in-out infinite; + border-radius: 999px; + transition: transform .15s, box-shadow .15s; + box-shadow: 0 0 8px rgba(139, 92, 246, .35); +} + +.update-pill:hover { + transform: scale(1.05); + box-shadow: 0 0 14px rgba(139, 92, 246, .5); } -.update-badge:hover { background: #241a00; } +.update-pill svg { flex-shrink: 0; } /* ══════════════════════════════════════════ 24. DIALOG / MODAL @@ -2219,6 +2225,131 @@ dialog footer { gap: 8px; } +/* Update modal */ +#update-modal { + max-width: 40rem; + width: 90%; + padding: 0; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; +} + +.update-modal-content { margin: 0; } + +.update-modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 24px 28px 18px; + border-bottom: 1px solid var(--border); +} + +.update-modal-header h3 { font-size: 15px; margin-bottom: 4px; } + +.update-modal-changelog { + padding: 20px 28px; + max-height: 400px; + overflow-y: auto; + font-size: 14px; + line-height: 1.7; + color: var(--muted2); +} + +.update-modal-changelog .changelog-section { margin-bottom: 16px; } +.update-modal-changelog .changelog-section:last-child { margin-bottom: 0; } + +.update-modal-changelog .changelog-heading { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted2); + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 8px; +} + +.changelog-icon { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.changelog-icon-added { color: var(--green); } +.changelog-icon-changed { color: var(--accent); } +.changelog-icon-fixed { color: var(--green); } +.changelog-icon-removed { color: var(--red); } + +.update-modal-changelog .changelog-list { + margin: 0; + padding: 0; + list-style: none; +} + +.update-modal-changelog .changelog-list li { + position: relative; + padding-left: 16px; + margin-bottom: 8px; + line-height: 1.6; +} + +.update-modal-changelog .changelog-list li::before { + content: ""; + position: absolute; + left: 0; + top: 9px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--muted); +} + +.update-modal-changelog .changelog-text { margin: 0 0 8px; } + +.update-modal-progress { + padding: 16px 24px; + max-height: 200px; + overflow-y: auto; + font-family: var(--mono); + font-size: 12px; + line-height: 1.7; + color: var(--muted2); + background: var(--bg); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.update-modal-footer { + margin-top: 0; + border-top: none; + padding: 18px 28px 18px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.spinner-sm { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; + vertical-align: middle; + margin-right: 4px; +} + /* ══════════════════════════════════════════ 25. TOOLTIP ══════════════════════════════════════════ */ @@ -2459,7 +2590,9 @@ dialog footer { .text-primary { color: var(--accent); } .text-success { color: var(--green); } .text-error { color: var(--red); } +.text-warning { color: var(--yellow); } .text-muted { color: var(--muted); } +.text-accent { color: var(--accent); } .border-b { border-bottom: 1px solid var(--border); } .border-t { border-top: 1px solid var(--border); } .overflow-auto { overflow-y: auto; } diff --git a/web/templates/layout.html b/web/templates/layout.html index ddac18b..851779f 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -55,6 +55,12 @@
+
{{.TargetName}} @@ -64,14 +70,6 @@ hx-trigger="load, every 10s" hx-swap="innerHTML">
- - + +
+ +
+ + +
+ + +
From 310a018cdfe7212efbd5196d27120560105aad19 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 09:42:49 -0300 Subject: [PATCH 18/25] chore: installs binary to ~/.local/bin for user-writable self-update --- install-dev.sh | 31 +++++++++++++++++++++---------- install.sh | 50 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/install-dev.sh b/install-dev.sh index fed117d..218d43e 100755 --- a/install-dev.sh +++ b/install-dev.sh @@ -4,18 +4,30 @@ set -euo pipefail BINARY_NAME="keel" -INSTALL_DIR="/usr/local/bin" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BIN_PATH="${SCRIPT_DIR}/bin/${BINARY_NAME}" -# Data directory: Linux uses /var/lib/keel, macOS uses ~/.keel OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +REAL_USER="${SUDO_USER:-$(whoami)}" +REAL_HOME=$(eval echo "~${REAL_USER}") + +# Install to user-writable directory +if [ "$(id -u)" = "0" ]; then + INSTALL_DIR="${REAL_HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" + chown "$REAL_USER" "$INSTALL_DIR" +else + INSTALL_DIR="${HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" +fi + +# Data directory if [ "$OS" = "darwin" ]; then - REAL_USER="${SUDO_USER:-$(whoami)}" - REAL_HOME=$(eval echo "~${REAL_USER}") KEEL_DIR="${REAL_HOME}/.keel" -else +elif [ "$(id -u)" = "0" ]; then KEEL_DIR="/var/lib/keel" +else + KEEL_DIR="${HOME}/.keel" fi bold=$(tput bold 2>/dev/null || true) @@ -27,7 +39,6 @@ info() { echo "${bold}==>${reset} $*"; } ok() { echo "${green} ✓${reset} $*"; } fail() { echo "${red}error:${reset} $*" >&2; exit 1; } -[ "$(id -u)" = "0" ] || fail "run with sudo: sudo bash install-dev.sh" [ -f "$BIN_PATH" ] || fail "bin/keel not found — run 'make build' first" # ── install binary ───────────────────────────────────────────────────────────── @@ -114,10 +125,10 @@ setup_ghcr() { setup_ghcr # ── ownership ───────────────────────────────────────────────────────────────── -REAL_USER="${REAL_USER:-${SUDO_USER:-}}" -if [ -n "$REAL_USER" ]; then - chown -R "${REAL_USER}" "${KEEL_DIR}" - ok "ownership of ${KEEL_DIR} set to ${REAL_USER}" +if [ "$(id -u)" = "0" ] && [ -n "${SUDO_USER:-}" ]; then + chown -R "${SUDO_USER}" "${KEEL_DIR}" + chown "${SUDO_USER}" "${INSTALL_DIR}/${BINARY_NAME}" + ok "ownership set to ${SUDO_USER} (self-update enabled)" fi # ── done ────────────────────────────────────────────────────────────────────── diff --git a/install.sh b/install.sh index b28dc92..14cce35 100755 --- a/install.sh +++ b/install.sh @@ -4,9 +4,20 @@ set -euo pipefail BINARY_NAME="keel" -INSTALL_DIR="/usr/local/bin" RELEASES_BASE="https://github.com/getkaze/keel/releases" +# Install directory: user-writable ~/.local/bin preferred, /usr/local/bin as fallback. +REAL_USER="${SUDO_USER:-$(whoami)}" +REAL_HOME=$(eval echo "~${REAL_USER}") +if [ "$(id -u)" = "0" ]; then + INSTALL_DIR="${REAL_HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" + chown "$REAL_USER" "$INSTALL_DIR" +else + INSTALL_DIR="${HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" +fi + # ── color helpers ────────────────────────────────────────────────────────────── bold=$(tput bold 2>/dev/null || true) reset=$(tput sgr0 2>/dev/null || true) @@ -18,8 +29,6 @@ ok() { echo "${green} ✓${reset} $*"; } fail() { echo "${red}error:${reset} $*" >&2; exit 1; } # ── sanity checks ───────────────────────────────────────────────────────────── -[ "$(id -u)" = "0" ] || fail "please run with sudo:\n\n curl -fsSL https://getkaze.dev/keel/install.sh | sudo bash" - OS="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$OS" in linux) ;; @@ -35,13 +44,13 @@ case "$ARCH" in *) fail "unsupported architecture: $ARCH" ;; esac -# Data directory: Linux uses /var/lib/keel, macOS uses ~/.keel +# Data directory: Linux uses /var/lib/keel (with sudo) or ~/.keel, macOS uses ~/.keel if [ "$OS" = "darwin" ]; then - REAL_USER="${SUDO_USER:-$(whoami)}" - REAL_HOME=$(eval echo "~${REAL_USER}") KEEL_DIR="${REAL_HOME}/.keel" -else +elif [ "$(id -u)" = "0" ]; then KEEL_DIR="/var/lib/keel" +else + KEEL_DIR="${HOME}/.keel" fi # Detect KEEL_VERSION (optional — defaults to latest) @@ -155,17 +164,30 @@ setup_ghcr() { setup_ghcr # ── ownership ────────────────────────────────────────────────────────────────── -# Give the calling (non-root) user ownership of the data directory -# so that `keel target` can be run without sudo. -REAL_USER="${REAL_USER:-${SUDO_USER:-}}" -if [ -n "$REAL_USER" ]; then - chown -R "${REAL_USER}" "${KEEL_DIR}" - ok "ownership of ${KEEL_DIR} set to ${REAL_USER}" +# Give the calling (non-root) user ownership so keel can self-update without sudo. +if [ "$(id -u)" = "0" ] && [ -n "${SUDO_USER:-}" ]; then + chown -R "${SUDO_USER}" "${KEEL_DIR}" + chown "${SUDO_USER}" "${INSTALL_DIR}/${BINARY_NAME}" + ok "ownership set to ${SUDO_USER} (self-update enabled)" fi # ── done ─────────────────────────────────────────────────────────────────────── echo "" echo "${bold}keel ${VERSION} installed successfully!${reset}" + +# Check if install dir is in PATH +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + echo "" + echo "${bold}Note:${reset} ${INSTALL_DIR} is not in your PATH." + echo " Add it to your shell profile:" + echo "" + echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" + echo "" + ;; +esac + echo "" echo "Quick start:" echo " keel target # show active target" @@ -175,4 +197,4 @@ echo " keel stop # stop all services" echo " keel reset --all # recreate all containers" echo " keel # open the web dashboard (port 60000)" echo "" -echo "Docs: https://getkaze.dev/docs" +echo "Docs: https://getkaze.dev/keel/docs" From d79d529a9fd903f84e84062098b87623469eb5e5 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 09:42:53 -0300 Subject: [PATCH 19/25] fix: adds scp recursive flag for directory sync --- internal/cli/runner.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/runner.go b/internal/cli/runner.go index d25fe77..bbd3f47 100644 --- a/internal/cli/runner.go +++ b/internal/cli/runner.go @@ -183,7 +183,7 @@ func (r *Runner) SyncFiles(ctx context.Context, svc model.Service, keelDir strin } // Build scp args with the same SSH options (key, jump host). - scpArgs := []string{"-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "LogLevel=ERROR"} + scpArgs := []string{"-r", "-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "LogLevel=ERROR"} if r.target.SSHKey != "" { scpArgs = append(scpArgs, "-i", keelssh.ExpandHome(r.target.SSHKey)) } From 3619bb8baa96dadcb2c10379c92753efbe357a7c Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 09:43:02 -0300 Subject: [PATCH 20/25] chore: generates release body from changelog and expands README --- .github/workflows/release.yml | 64 ++++++++++++++++++++++--------- README.md | 71 ++++++++++++++++++++++++++++++----- 2 files changed, 109 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96cc760..1a11220 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,27 +88,57 @@ jobs: with: path: artifacts - - name: Create release - uses: softprops/action-gh-release@v2 - with: - name: ${{ github.ref_name }} - body: | - ## keel ${{ github.ref_name }} + - name: Build release body + run: | + VERSION="${{ github.ref_name }}" + SEMVER="${VERSION#v}" + + # Extract the section for this version from CHANGELOG.md + NOTES=$(awk -v ver="$SEMVER" ' + /^## \[/ { + if (found) exit + if (index($0, "[" ver "]")) { found=1; next } + } + found && /^---$/ { exit } + found { print } + ' CHANGELOG.md) + + # Build the release body + cat > /tmp/release-body.md << 'HEADER' + ## What's new + HEADER - ### Installation + if [ -n "$(echo "$NOTES" | tr -d '[:space:]')" ]; then + echo "$NOTES" >> /tmp/release-body.md + else + echo "See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/master/CHANGELOG.md) for details." >> /tmp/release-body.md + fi - ```bash - curl -fsSL https://getkaze.dev/keel/install.sh | sudo bash - ``` + cat >> /tmp/release-body.md << FOOTER - ### Manual download + --- - | Platform | Link | - |---|---| - | Linux amd64 | [keel-linux-amd64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-linux-amd64) | - | Linux arm64 | [keel-linux-arm64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-linux-arm64) | - | macOS amd64 | [keel-darwin-amd64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-darwin-amd64) | - | macOS arm64 | [keel-darwin-arm64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-darwin-arm64) | + ## Installation + + \`\`\`bash + curl -fsSL https://getkaze.dev/keel/install.sh | sudo bash + \`\`\` + + ## Manual download + + | Platform | Link | + |---|---| + | Linux amd64 | [keel-linux-amd64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-linux-amd64) | + | Linux arm64 | [keel-linux-arm64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-linux-arm64) | + | macOS amd64 | [keel-darwin-amd64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-darwin-amd64) | + | macOS arm64 | [keel-darwin-arm64](${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/keel-darwin-arm64) | + FOOTER + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body_path: /tmp/release-body.md files: | artifacts/keel-linux-amd64/keel-linux-amd64 artifacts/keel-linux-arm64/keel-linux-arm64 diff --git a/README.md b/README.md index 795dba4..8b3fc48 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@
- [Install](#install) · [Usage](#usage) · [Features](#features) · [Seeders](#seeders) · [Dev Mode](#dev-mode) · [Remote Targets](#remote-targets) · [Stack](#stack) · [Build](#build) + [Install](#install) · [Usage](#usage) · [Features](#features) · [Seeders](#seeders) · [Dev Mode](#dev-mode) · [Remote Targets](#remote-targets) · [Service Config](#service-config) · [Stack](#stack) · [Build](#build) · [Data Directory](#data-directory)
@@ -22,7 +22,7 @@ ## What is Keel -**Keel** (the keel of a ship — the hidden structure that keeps everything aligned) is a self-hosted web dashboard for managing Docker environments — local or remote via SSH — from a single Go binary (~10MB, no external dependencies).. +**Keel** (the keel of a ship — the hidden structure that keeps everything aligned) is a self-hosted web dashboard for managing Docker environments — local or remote via SSH — from a single Go binary (~10MB, no external dependencies). ``` keel @@ -38,7 +38,7 @@ That's it. Open `http://localhost:60000` and you have a full dashboard with live curl -fsSL https://getkaze.dev/keel/install.sh | sudo bash ``` -This installs the binary to `/usr/local/bin/keel` and creates the data directory at `/var/lib/keel`. +This installs the binary to `~/.local/bin/keel` and creates the data directory at `/var/lib/keel`. The binary is owned by your user, enabling self-update from the dashboard without sudo. --- @@ -51,8 +51,10 @@ keel # Container operations keel start # start all services keel start redis mysql # start specific services +keel start infra # start all services in a group keel stop # stop all services keel stop traefik # stop specific service +keel stop tools # stop all services in a group keel reset --all # destroy and recreate all containers keel reset redis # recreate a single service @@ -156,7 +158,16 @@ Each seeder is a JSON file in `data/seeders/`: |-------|-------------| | `target` | Container name to exec into | | `order` | Execution order (lower = first) | -| `commands` | Ordered list of `{ name, command }` steps | +| `commands` | Ordered list of steps (see below) | + +Each command entry supports: + +| Field | Description | +|-------|-------------| +| `name` | Step identifier | +| `command` | Single command to execute via `docker exec` | +| `script` | Filename of a script in the seeders directory (alternative to `command`) | +| `interpreter` | Interpreter to pipe the script into — e.g. `bash`, `python3` (used with `script`) | Seeders can be run from the UI (Seeders page) or via CLI: @@ -207,16 +218,36 @@ Example service config: Keel supports multiple Docker targets — local or remote via SSH tunnel. + ```json -// /var/lib/keel/data/targets.json { "targets": { "local": { "host": "127.0.0.1" }, - "ec2": { "host": "user@1.2.3.4", "ssh_key": "~/.ssh/id_ed25519", "external_ip": "1.2.3.4" } - } + "ec2": { + "host": "1.2.3.4", + "ssh_user": "ubuntu", + "ssh_key": "~/.ssh/id_ed25519", + "ssh_jump": "ec2-user@bastion.example.com", + "external_ip": "1.2.3.4", + "port_bind": "0.0.0.0", + "description": "AWS EC2 Ubuntu" + } + }, + "default": "local" } ``` +| Field | Description | +|-------|-------------| +| `host` | IP address or hostname | +| `ssh_user` | SSH user for remote targets (omit for local) | +| `ssh_key` | Path to SSH private key (supports `~/`) | +| `ssh_jump` | Bastion/jump host for multi-hop SSH | +| `external_ip` | External IP used by `keel hosts setup` | +| `port_bind` | Bind interface for ports — `127.0.0.1` (default) or `0.0.0.0` | +| `description` | Human-readable target label | +| `default` | Root-level field — default target name | + ```bash keel target ec2 # switch to remote keel start # commands now execute on ec2 via SSH @@ -237,11 +268,13 @@ Each service is a JSON file in `data/services/`. Full example: "group": "database", "hostname": "keel-redis", "image": "redis:7", - "registry": "dockerhub", "network": "keel-net", "ports": { "internal": 6379, "external": 6379 }, "environment": { "REDIS_ARGS": "--maxmemory 256mb" }, "volumes": ["keel-redis-data:/data"], + "command": "redis-server --save 60 1", + "files": ["data/config/redis.conf:/etc/redis/redis.conf"], + "start_order": 1, "ram_estimate_mb": 256, "dashboard_url": "http://localhost:8001", "health_check": { @@ -262,6 +295,26 @@ Each service is a JSON file in `data/services/`. Full example: } ``` +| Field | Description | +|-------|-------------| +| `name` | Unique service identifier | +| `group` | Logical grouping — `infra` starts first, then seeders, then the rest | +| `hostname` | Docker container hostname | +| `image` | Docker image `name:tag` | +| `registry` | Set to `ghcr` to auto-login with stored credentials (omit for public images) | +| `network` | Docker network (defaults to `keel-net`) | +| `ports` | `{ internal, external }` port mapping | +| `environment` | Environment variables passed to the container | +| `volumes` | Volume mounts — named volumes, bind mounts, or config files | +| `command` | Override container CMD | +| `files` | Config files mounted read-only into the container; synced via `scp` on remote targets (`local:container`) | +| `start_order` | Startup priority (lower = earlier, 0 = last) | +| `ram_estimate_mb` | Display hint for the dashboard | +| `dashboard_url` | External URL — shows an **OPEN** button in the UI | +| `health_check` | HTTP or command-based health check config | +| `logs` | Log sources — `docker` or `file` with optional `host_path` | +| `dev` | Development mode config — `dockerfile`, `command`, `cap_add` | + --- ## Stack @@ -273,7 +326,7 @@ Each service is a JSON file in `data/services/`. Full example: | Design | Kaze design system — Recursive variable font | | Assets | `go:embed` — single binary, ~10MB | | Icons | Lucide v0.469.0 (self-hosted SVG sprite) | -| Metrics | `/proc/stat`, `/proc/meminfo`, `syscall.Statfs`, `docker stats` | +| Metrics | gopsutil v4, `docker stats` | --- From 8934b4a9375522aeb49758fb0130df38c831e6d3 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Tosta Date: Wed, 18 Mar 2026 09:54:51 -0300 Subject: [PATCH 21/25] docs: adds prerequisites, v0.3 highlights, and removes phantom -open flag --- README.md | 19 +++++++++++++++---- internal/cli/cli.go | 1 - 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8b3fc48..f2dea44 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@
- [Install](#install) · [Usage](#usage) · [Features](#features) · [Seeders](#seeders) · [Dev Mode](#dev-mode) · [Remote Targets](#remote-targets) · [Service Config](#service-config) · [Stack](#stack) · [Build](#build) · [Data Directory](#data-directory) + [Prerequisites](#prerequisites) · [Install](#install) · [Usage](#usage) · [Features](#features) · [Seeders](#seeders) · [Dev Mode](#dev-mode) · [Remote Targets](#remote-targets) · [Service Config](#service-config) · [Stack](#stack) · [Build](#build) · [Data Directory](#data-directory) @@ -32,6 +32,14 @@ That's it. Open `http://localhost:60000` and you have a full dashboard with live --- +## Prerequisites + +- **Docker** — local install or remote host with Docker via SSH +- **SSH key pair** — required for remote targets +- **sudo** — only for `keel hosts setup` (modifies `/etc/hosts`) + +--- + ## Install ```bash @@ -254,7 +262,7 @@ keel start # commands now execute on ec2 via SSH keel target local # switch back ``` -For remote targets, an SSH tunnel is opened automatically, forwarding the remote Docker socket to a local Unix socket (`/tmp/keel-docker-.sock`). +For remote targets, an SSH tunnel is opened automatically, forwarding the remote Docker socket to a local Unix socket (`/tmp/keel-docker-.sock`). The tunnel is monitored with automatic reconnection and exponential backoff — a live status dot in the topbar shows the connection state via SSE. --- @@ -326,7 +334,7 @@ Each service is a JSON file in `data/services/`. Full example: | Design | Kaze design system — Recursive variable font | | Assets | `go:embed` — single binary, ~10MB | | Icons | Lucide v0.469.0 (self-hosted SVG sprite) | -| Metrics | gopsutil v4, `docker stats` | +| Metrics | gopsutil v4, `docker stats`, remote cache (10s) | --- @@ -351,8 +359,11 @@ sudo bash install-dev.sh # Run with live asset reloading keel -dev -# Run tests +# Run tests (155 unit tests) go test ./... + +# Run with race detection +go test -race ./... ``` --- diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e096e35..81bda5e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -491,7 +491,6 @@ Dashboard flags: -bind bind address (default 127.0.0.1) -keel-dir data directory (default: OS-dependent) -dev serve web assets from filesystem (dev mode) - -open open browser after starting Environment: KEEL_DIR data directory override for CLI commands From 57982ce6ee0135304fd009e21c022557e3ba8bf2 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Date: Mon, 23 Mar 2026 11:28:01 -0300 Subject: [PATCH 22/25] docs: updates CLI help example to use generic service name --- internal/cli/cli.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 81bda5e..bb98ace 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -506,6 +506,6 @@ Examples: keel stop traefik stop traefik keel reset --all recreate all containers from services/*.json keel reset redis recreate only redis - keel dev mchtracker ~/projects/mchtracker run mchtracker with local code + hot reload + keel dev api ~/projects/api run api with local code + hot reload `, version) } From e56e8676e8ca1ad45e508b321603614258996fac Mon Sep 17 00:00:00 2001 From: Mateus Metzker Date: Mon, 23 Mar 2026 11:37:55 -0300 Subject: [PATCH 23/25] docs: moves v0.3 changelog entries back to unreleased --- CHANGELOG.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2867c83..168e64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,6 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ---- - -## [0.3] — 2026-03-18 - ### Added - CmdRunner abstraction — local and remote Docker execution behind a unified interface (@mateusmetzker) @@ -155,8 +151,7 @@ Initial public release (@mateusmetzker). - Data directory: `/var/lib/keel` (Linux) or `~/.keel` (macOS) - Install script: `curl -fsSL https://getkaze.dev/keel/install.sh | sudo bash` -[Unreleased]: https://github.com/getkaze/keel/compare/v0.3...HEAD -[0.3]: https://github.com/getkaze/keel/compare/v0.2...v0.3 +[Unreleased]: https://github.com/getkaze/keel/compare/v0.2...HEAD [0.2]: https://github.com/getkaze/keel/compare/v0.1.1...v0.2 [0.1.1]: https://github.com/getkaze/keel/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/getkaze/keel/releases/tag/v0.1.0 From decaf317310cc9c56cd2290481cb3b4ea928c612 Mon Sep 17 00:00:00 2001 From: Mateus Metzker Date: Mon, 23 Mar 2026 11:37:58 -0300 Subject: [PATCH 24/25] chore: adds dev branch to CI workflow triggers --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a342159..d2fa1bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [master, dev] pull_request: - branches: [master] + branches: [master, dev] jobs: test: From a476c8f28bab2d5829f75fb0d2125627005980bf Mon Sep 17 00:00:00 2001 From: Mateus Metzker Date: Mon, 23 Mar 2026 11:49:59 -0300 Subject: [PATCH 25/25] docs: tags changelog entries as v0.3 release --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 168e64e..43b5411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3] — 2026-03-23 + ### Added - CmdRunner abstraction — local and remote Docker execution behind a unified interface (@mateusmetzker) @@ -151,7 +153,8 @@ Initial public release (@mateusmetzker). - Data directory: `/var/lib/keel` (Linux) or `~/.keel` (macOS) - Install script: `curl -fsSL https://getkaze.dev/keel/install.sh | sudo bash` -[Unreleased]: https://github.com/getkaze/keel/compare/v0.2...HEAD +[Unreleased]: https://github.com/getkaze/keel/compare/v0.3...HEAD +[0.3]: https://github.com/getkaze/keel/compare/v0.2...v0.3 [0.2]: https://github.com/getkaze/keel/compare/v0.1.1...v0.2 [0.1.1]: https://github.com/getkaze/keel/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/getkaze/keel/releases/tag/v0.1.0