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 {
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)
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