Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .grype.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,17 @@ ignore:
# https://github.com/docker-library/docker
# Remove this entry once `grype kooldev/kool:<tag>` no longer reports it.
- vulnerability: CVE-2026-27143
# Inherited from docker:29-cli (Alpine 3.22). libcurl 8.20.0-r1 is
# transitively pulled in by `apk add git`; fixed in 8.21.0-r0. Will clear
# automatically when docker-library/docker rebuilds the 29-cli image with
# an updated Alpine base. Tracked upstream:
# https://github.com/docker-library/docker
# Remove these entries once `grype kooldev/kool:<tag>` no longer reports them.
- vulnerability: CVE-2026-8925
- vulnerability: CVE-2026-11856
- vulnerability: CVE-2026-9079
- vulnerability: CVE-2026-10536
- vulnerability: CVE-2026-8927
- vulnerability: CVE-2026-8924
- vulnerability: CVE-2026-8926
- vulnerability: CVE-2026-11564
68 changes: 68 additions & 0 deletions commands/info.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"encoding/json"
"fmt"
"kool-dev/kool/core/builder"
"kool-dev/kool/core/environment"
Expand All @@ -19,6 +20,15 @@ type KoolInfo struct {
cmdDocker, cmdDockerCompose builder.Command
}

type infoOutputJSON struct {
KoolVersion string `json:"kool_version"`
KoolBinPath string `json:"kool_bin_path"`
DockerVersion string `json:"docker_version"`
DockerBinPath string `json:"docker_bin_path"`
DockerComposeVersion string `json:"docker_compose_version"`
Env map[string]string `json:"env"`
}

// NewInfoCmd initializes new kool info command
func NewInfoCmd(info *KoolInfo) *cobra.Command {
return &cobra.Command{
Expand Down Expand Up @@ -57,6 +67,10 @@ func (i *KoolInfo) Execute(args []string) (err error) {
filter = args[0]
}

if i.Shell().IsJSONOutput() {
return i.executeJSON(filter)
}

// kool CLI info
i.Shell().Println("Kool Version ", version)
if output, err = os.Executable(); err != nil {
Expand Down Expand Up @@ -110,3 +124,57 @@ func (i *KoolInfo) Execute(args []string) (err error) {

return
}

func (i *KoolInfo) executeJSON(filter string) (err error) {
var (
output string
info infoOutputJSON
)

info.KoolVersion = version

if output, err = os.Executable(); err != nil {
return
}
info.KoolBinPath = output

if output, err = i.Shell().Exec(i.cmdDocker); err != nil {
return
}
info.DockerVersion = output

if err = i.shell.LookPath(i.cmdDocker); err != nil {
return
}
info.DockerBinPath, _ = exec.LookPath(i.cmdDocker.Cmd())

if output, err = i.Shell().Exec(i.cmdDockerCompose); err != nil {
i.Shell().Warning("Docker Compose:", err.Error())
i.Shell().Error(fmt.Errorf("you need to have Docker Compose V2 available; make sure to update your Docker installation"))
return
}
info.DockerComposeVersion = output

info.Env = map[string]string{}
for _, envVar := range i.envStorage.All() {
if !strings.Contains(envVar, filter) {
continue
}
parts := strings.SplitN(envVar, "=", 2)
key, value := parts[0], ""
if len(parts) > 1 {
value = parts[1]
}
if key == "KOOL_API_TOKEN" {
value = "***************** [redacted]"
}
info.Env[key] = value
}

var payload []byte
if payload, err = json.Marshal(info); err != nil {
return
}
i.Shell().Println(string(payload))
return
}
72 changes: 72 additions & 0 deletions commands/info_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"encoding/json"
"kool-dev/kool/core/builder"
"kool-dev/kool/core/environment"
"kool-dev/kool/core/shell"
Expand Down Expand Up @@ -71,3 +72,74 @@ func execInfoCommand(cmd *cobra.Command, f *KoolInfo) (output string, err error)
output = strings.Join(f.shell.(*shell.FakeShell).OutLines, "\n")
return
}

func TestInfoJSONOutput(t *testing.T) {
f := fakeKoolInfo()
f.shell.(*shell.FakeShell).MockIsJSONOutput = true
f.cmdDocker.(*builder.FakeCommand).MockExecOut = "Docker version 29.0.0"
f.cmdDocker.(*builder.FakeCommand).MockCmd = "docker"
f.cmdDockerCompose.(*builder.FakeCommand).MockExecOut = "Docker Compose version v2.30.0"
f.cmdDockerCompose.(*builder.FakeCommand).MockCmd = "docker"

setupInfoTest(f)
f.envStorage.Set("KOOL_OUTPUT", "json")

cmd := NewInfoCmd(f)

output, err := execInfoCommand(cmd, f)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var info infoOutputJSON
if err := json.Unmarshal([]byte(output), &info); err != nil {
t.Fatalf("failed to parse json output: %v\nraw: %s", err, output)
}

if info.KoolVersion == "" {
t.Error("expected non-empty kool_version")
}
if info.DockerVersion != "Docker version 29.0.0" {
t.Errorf("expected docker_version 'Docker version 29.0.0', got '%s'", info.DockerVersion)
}
if info.DockerComposeVersion != "Docker Compose version v2.30.0" {
t.Errorf("expected docker_compose_version, got '%s'", info.DockerComposeVersion)
}
if info.Env["KOOL_TESTING"] != "1" {
t.Errorf("expected env KOOL_TESTING=1, got '%s'", info.Env["KOOL_TESTING"])
}
if info.Env["KOOL_OUTPUT"] != "json" {
t.Errorf("expected env KOOL_OUTPUT=json, got '%s'", info.Env["KOOL_OUTPUT"])
}
}

func TestInfoJSONOutputRedactsAPIToken(t *testing.T) {
f := fakeKoolInfo()
f.shell.(*shell.FakeShell).MockIsJSONOutput = true
f.cmdDocker.(*builder.FakeCommand).MockExecOut = "Docker version 29.0.0"
f.cmdDocker.(*builder.FakeCommand).MockCmd = "docker"
f.cmdDockerCompose.(*builder.FakeCommand).MockExecOut = "Docker Compose version v2.30.0"
f.cmdDockerCompose.(*builder.FakeCommand).MockCmd = "docker"

f.envStorage.Set("KOOL_API_TOKEN", "super-secret-token")

cmd := NewInfoCmd(f)

output, err := execInfoCommand(cmd, f)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if strings.Contains(output, "super-secret-token") {
t.Error("KOOL_API_TOKEN value should be redacted in JSON output")
}

var info infoOutputJSON
if err := json.Unmarshal([]byte(output), &info); err != nil {
t.Fatalf("failed to parse json output: %v", err)
}

if info.Env["KOOL_API_TOKEN"] == "super-secret-token" {
t.Error("KOOL_API_TOKEN should be redacted")
}
}
2 changes: 1 addition & 1 deletion commands/kool_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func NewKoolTask(message string, service KoolService) *DefaultKoolTask {

// Run runs task
func (t *DefaultKoolTask) Run(args []string) (err error) {
if !t.Shell().IsTerminal() {
if !t.Shell().IsTerminal() || t.Shell().IsJSONOutput() {
return t.Execute(args)
}

Expand Down
86 changes: 86 additions & 0 deletions commands/logs.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package commands

import (
"bufio"
"encoding/json"
"kool-dev/kool/core/builder"
"os"
"os/exec"
"strconv"
"strings"

Expand All @@ -14,6 +18,13 @@ type KoolLogsFlags struct {
Follow bool
}

type logEntryJSON struct {
Service string `json:"service"`
Message string `json:"message"`
}

var execLogsCmd = exec.Command

// KoolLogs holds handlers and functions to implement the logs command logic
type KoolLogs struct {
DefaultKoolService
Expand Down Expand Up @@ -65,6 +76,10 @@ func (l *KoolLogs) Execute(args []string) (err error) {
l.logs.AppendArgs("--follow")
}

if l.Shell().IsJSONOutput() {
return l.printLogsJSON(args...)
}

err = l.Shell().Interactive(l.logs, args...)
return
}
Expand All @@ -86,3 +101,74 @@ the command to follow the log output (i.e. 'kool logs -f [SERVICE...]').`,
logsCmd.Flags().BoolVarP(&logs.Flags.Follow, "follow", "f", false, "Follow log output.")
return
}

func (l *KoolLogs) printLogsJSON(args ...string) (err error) {
if l.Flags.Follow {
return l.streamLogsJSON(args...)
}

var output string
if output, err = l.Shell().Exec(l.logs, args...); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-follow path goes through Shell().Exec, which uses CombinedOutput β€” so docker compose's own stderr chatter (WARN[0000] ..., orphan-container notices, deprecation warnings) is interleaved into output and then fed line-by-line through parseLogLine, producing bogus log entries in the JSON stream.

The --follow path gets this right by taking only StdoutPipe(). Worth making the two consistent, so the same command with and without -f doesn't yield structurally different data.

return
}

for _, line := range strings.Split(output, "\n") {
if line = strings.TrimSpace(line); line == "" {
continue
}
entry := parseLogLine(line)
var payload []byte
if payload, err = json.Marshal(entry); err != nil {
return
}
l.Shell().Println(string(payload))
}
return
}

func (l *KoolLogs) streamLogsJSON(args ...string) (err error) {
cmdArgs := l.logs.Args()
if len(args) > 0 {
cmdArgs = append(cmdArgs, args...)
}
cmd := execLogsCmd(l.logs.Cmd(), cmdArgs...)
cmd.Env = os.Environ()
cmd.Stderr = l.Shell().ErrStream()

stdout, e := cmd.StdoutPipe()
if e != nil {
err = e
return
}

if err = cmd.Start(); err != nil {
return
}

scanner := bufio.NewScanner(stdout)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with this scanner in the follow path:

  1. 64KB token limit. bufio.NewScanner defaults to bufio.MaxScanTokenSize. A single log line longer than that makes Scan() return false with bufio.ErrTooLong. That is not far-fetched for JSON-logging apps or stack traces.
  2. scanner.Err() is discarded. When the above happens the loop just exits, err = cmd.Wait() is called on a process whose stdout pipe is no longer being drained, and the command blocks once the pipe buffer fills. From the agent's point of view kool logs -f --output json silently stops emitting and hangs.

Suggest raising the buffer and checking the error:

scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
    ...
}
if e := scanner.Err(); e != nil {
    _ = cmd.Wait()
    return e
}

Or drop the scanner entirely for a bufio.Reader + ReadString('\n') loop, which has no line-length ceiling.

for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
entry := parseLogLine(line)
if payload, e := json.Marshal(entry); e == nil {
l.Shell().Println(string(payload))
}
}

err = cmd.Wait()
return
}

// parseLogLine parses a docker-compose log line into a logEntryJSON.
// Docker compose log format: "service_name | message" (with optional padding).
// If the line doesn't match, service is empty and message is the full line.
func parseLogLine(line string) logEntryJSON {
if idx := strings.Index(line, "|"); idx >= 0 {
service := strings.TrimSpace(line[:idx])
message := strings.TrimSpace(line[idx+1:])
return logEntryJSON{Service: service, Message: message}
}
return logEntryJSON{Service: "", Message: line}
}
Loading
Loading