Skip to content
Merged
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
17 changes: 16 additions & 1 deletion pkg/cli/docker_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ import (

var dockerImagesLog = logger.New("cli:docker_images")

// DockerUnavailableError is returned when the Docker daemon is not accessible.
// This is distinct from transient errors (e.g., images being downloaded) and signals
// that Docker is not installed or not running on the host system.
// Callers can use errors.As to check for this type and take appropriate action,
// such as skipping static analysis but still running the compile step.
type DockerUnavailableError struct {
Message string
}

func (e *DockerUnavailableError) Error() string {
return e.Message
}

// DockerImages defines the Docker images used by the compile tool's static analysis scanners
const (
ZizmorImage = "ghcr.io/zizmorcore/zizmor:latest"
Expand Down Expand Up @@ -220,7 +233,9 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use
if len(requestedTools) > 1 {
verb = "require"
}
return fmt.Errorf("docker is not available (cannot connect to Docker daemon). %s %s Docker. Please install and start Docker, or set %s to skip static analysis", strings.Join(requestedTools, " and "), verb, strings.Join(paramsList, " and "))
return &DockerUnavailableError{
Message: fmt.Sprintf("docker is not available (cannot connect to Docker daemon). %s %s Docker. Please install and start Docker, or set %s to skip static analysis", strings.Join(requestedTools, " and "), verb, strings.Join(paramsList, " and ")),
}
}

var missingImages []string
Expand Down
22 changes: 22 additions & 0 deletions pkg/cli/docker_images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cli

import (
"context"
"errors"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -562,3 +563,24 @@ func TestIsDockerAvailable_MockFalse(t *testing.T) {
}
ResetDockerPullState()
}

func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *testing.T) {
// Reset state before test
ResetDockerPullState()
SetMockDockerAvailable(false)

err := CheckAndPrepareDockerImages(context.Background(), false, false, true)
if err == nil {
t.Fatal("Expected error when Docker is unavailable, got nil")
}

// Verify the error is the typed DockerUnavailableError so callers can distinguish
// it from transient errors (e.g., images downloading).
var dockerUnavailableErr *DockerUnavailableError
if !errors.As(err, &dockerUnavailableErr) {
t.Errorf("Expected error to be *DockerUnavailableError, got %T: %v", err, err)
}

// Clean up
ResetDockerPullState()
}
76 changes: 65 additions & 11 deletions pkg/cli/mcp_tools_readonly.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,39 @@ Returns JSON array with validation results for each workflow:
default:
}

// dockerUnavailableWarning is set when Docker is not accessible but the compile
// should still proceed without the static-analysis tools. After the compile
// attempt, the warning is appended to workflow results in the JSON output so
// the caller knows linting was skipped, while preserving each workflow's
// valid/invalid status.
var dockerUnavailableWarning string

// Check if any static analysis tools are requested that require Docker images
if args.Zizmor || args.Poutine || args.Actionlint {
// Check if Docker images are available; if not, start downloading and return retry message
if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint); err != nil {
// Build per-workflow validation errors instead of throwing an MCP protocol error,
// so callers always receive consistent JSON regardless of the failure mode.
results := buildDockerErrorResults(args.Workflows, err.Error())
jsonBytes, jsonErr := json.Marshal(results)
if jsonErr != nil {
return nil, nil, newMCPError(jsonrpc.CodeInternalError, "failed to marshal docker error results", jsonErr.Error())
var dockerUnavailableErr *DockerUnavailableError
if errors.As(err, &dockerUnavailableErr) {
// Docker daemon is not running. Instead of failing every workflow,
// compile without the Docker-based tools and surface a warning so
// the caller knows static analysis was skipped.
dockerUnavailableWarning = err.Error()
args.Zizmor = false
args.Poutine = false
args.Actionlint = false
} else {
// Images are still downloading — ask the caller to retry.
// Build per-workflow validation errors instead of throwing an MCP protocol error,
// so callers always receive consistent JSON regardless of the failure mode.
results := buildDockerErrorResults(args.Workflows, err.Error())
jsonBytes, jsonErr := json.Marshal(results)
if jsonErr != nil {
return nil, nil, newMCPError(jsonrpc.CodeInternalError, "failed to marshal docker error results", jsonErr.Error())
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(jsonBytes)}},
}, nil, nil
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(jsonBytes)}},
}, nil, nil
}

// Check for cancellation after Docker image preparation
Expand Down Expand Up @@ -220,6 +239,13 @@ Returns JSON array with validation results for each workflow:
// and return it to the LLM
}

// When Docker was unavailable, inject a warning into every workflow result so the
// caller knows that static analysis was skipped — but does NOT mark valid
// workflows as invalid.
if dockerUnavailableWarning != "" {
outputStr = injectDockerUnavailableWarning(outputStr, dockerUnavailableWarning)
}

return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: outputStr},
Expand Down Expand Up @@ -380,8 +406,9 @@ Also returns pr_number, head_sha, check_runs, statuses, and total_count.`,
}

// buildDockerErrorResults builds a []ValidationResult with a config_error for each target
// workflow. It is used when Docker is unavailable so the compile tool returns consistent
// structured JSON instead of a protocol-level error.
// workflow. It is used when Docker images are still being downloaded (transient error) so
// the compile tool returns consistent structured JSON instead of a protocol-level error.
// For the persistent case where Docker is not available at all, see injectDockerUnavailableWarning.
func buildDockerErrorResults(requestedWorkflows []string, errMsg string) []ValidationResult {
// Determine which workflow names to report
var workflowNames []string
Expand Down Expand Up @@ -426,3 +453,30 @@ func buildDockerErrorResults(requestedWorkflows []string, errMsg string) []Valid
}
return results
}

// injectDockerUnavailableWarning parses the JSON compile output and appends a
// "docker_unavailable" warning to every workflow result. It is used when Docker
// is not running so the caller knows static analysis was skipped, while preserving
// the compile-time valid/invalid status of each workflow.
// If the JSON cannot be parsed the original output is returned unchanged.
func injectDockerUnavailableWarning(outputStr, warningMsg string) string {
var results []ValidationResult
if err := json.Unmarshal([]byte(outputStr), &results); err != nil {
// Can't parse — return original output so we don't lose information.
return outputStr
}

warning := CompileValidationError{
Type: "docker_unavailable",
Message: warningMsg,
}
for i := range results {
results[i].Warnings = append(results[i].Warnings, warning)
}

jsonBytes, err := json.Marshal(results)
if err != nil {
return outputStr
}
return string(jsonBytes)
}
81 changes: 81 additions & 0 deletions pkg/cli/mcp_tools_readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//go:build !integration

package cli

import (
"encoding/json"
"testing"
)

func TestInjectDockerUnavailableWarning_AddsWarningToValidResults(t *testing.T) {
// Simulate compile output where both workflows compiled successfully.
inputJSON := `[{"workflow":"a.md","valid":true,"errors":[],"warnings":[]},{"workflow":"b.md","valid":true,"errors":[],"warnings":[]}]`
warningMsg := "docker is not available (cannot connect to Docker daemon). actionlint requires Docker."

output := injectDockerUnavailableWarning(inputJSON, warningMsg)

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

if len(results) != 2 {
t.Fatalf("Expected 2 results, got %d", len(results))
}

for _, r := range results {
if !r.Valid {
t.Errorf("Workflow %s should still be valid after Docker unavailable warning", r.Workflow)
}
if len(r.Warnings) != 1 {
t.Errorf("Workflow %s should have 1 warning, got %d", r.Workflow, len(r.Warnings))
continue
}
if r.Warnings[0].Type != "docker_unavailable" {
t.Errorf("Expected warning type 'docker_unavailable', got '%s'", r.Warnings[0].Type)
}
if r.Warnings[0].Message != warningMsg {
t.Errorf("Expected warning message %q, got %q", warningMsg, r.Warnings[0].Message)
}
}
}

func TestInjectDockerUnavailableWarning_PreservesInvalidResults(t *testing.T) {
// One workflow failed to compile; the other succeeded.
inputJSON := `[{"workflow":"bad.md","valid":false,"errors":[{"type":"parse_error","message":"syntax error"}],"warnings":[]},{"workflow":"good.md","valid":true,"errors":[],"warnings":[]}]`
warningMsg := "docker is not available"

output := injectDockerUnavailableWarning(inputJSON, warningMsg)

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

if len(results) != 2 {
t.Fatalf("Expected 2 results, got %d", len(results))
}

// bad.md should remain invalid and still carry its original error.
if results[0].Valid {
t.Error("bad.md should remain invalid")
}
if len(results[0].Errors) != 1 || results[0].Errors[0].Type != "parse_error" {
t.Error("bad.md should still have its original parse_error")
}
// good.md should be valid with the warning appended.
if !results[1].Valid {
t.Error("good.md should still be valid")
}
if len(results[1].Warnings) != 1 || results[1].Warnings[0].Type != "docker_unavailable" {
t.Error("good.md should have the docker_unavailable warning")
}
}

func TestInjectDockerUnavailableWarning_InvalidJSONReturnedUnchanged(t *testing.T) {
invalidJSON := "not-valid-json"
output := injectDockerUnavailableWarning(invalidJSON, "some warning")
if output != invalidJSON {
t.Errorf("Expected original output to be returned unchanged for invalid JSON, got: %s", output)
}
}