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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*.swp
.env*
tempkey
.DS_Store
Binary file modified dist/pullpreview-linux-amd64
Binary file not shown.
194 changes: 116 additions & 78 deletions internal/pullpreview/deploy_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ func (i *Instance) runComposeOnRemoteContext(composeConfig []byte) error {
upArgs := []string{"up", "--wait", "--remove-orphans", "-d"}
upArgs = append(upArgs, i.ComposeOptions...)
if err := i.runComposeCommandWithConfig(env, contextName, composeConfig, upArgs...); err != nil {
i.emitComposeFailureReport(env, contextName, composeConfig, upArgs, err)
i.emitComposeFailureReport(env, contextName, upArgs, err)
return err
}
return nil
Expand Down Expand Up @@ -636,8 +636,25 @@ func mergeCommandOutput(stdout, stderr string) string {
return stdout + "\n" + stderr
}

func (i *Instance) emitComposeFailureReport(env []string, contextName string, composeConfig []byte, upArgs []string, upErr error) {
report := i.composeFailureReport(env, contextName, composeConfig, upArgs, upErr)
func (i *Instance) runDockerCommandOnContextCapture(env []string, contextName string, args ...string) (string, error) {
cmdArgs := []string{"--context", contextName}
cmdArgs = append(cmdArgs, args...)
cmd := exec.CommandContext(i.Context, "docker", cmdArgs...)
cmd.Env = env
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := mergeCommandOutput(stdout.String(), stderr.String())
if err != nil {
return output, fmt.Errorf("docker %s failed: %w", strings.Join(args, " "), err)
}
return output, nil
}

func (i *Instance) emitComposeFailureReport(env []string, contextName string, upArgs []string, upErr error) {
report := i.composeFailureReport(env, contextName, upArgs, upErr)
if strings.TrimSpace(report) == "" {
return
}
Expand All @@ -649,116 +666,137 @@ func (i *Instance) emitComposeFailureReport(env []string, contextName string, co
appendStepSummary(report, i.Logger)
}

func (i *Instance) composeFailureReport(env []string, contextName string, composeConfig []byte, upArgs []string, upErr error) string {
func (i *Instance) composeFailureReport(env []string, contextName string, upArgs []string, upErr error) string {
diagnostics := []string{}
psOutput, psErr := i.runComposeCommandCaptureWithConfig(env, contextName, composeConfig, "ps", "-a", "--format", "json")
if psErr != nil {
diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker compose ps -a --format json: %v", psErr))
psOutput, psErr = i.runComposeCommandCaptureWithConfig(env, contextName, composeConfig, "ps", "-a")
if psErr != nil {
diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker compose ps -a: %v", psErr))
}
projectFilter := "label=com.docker.compose.project=" + dockerProjectName

dockerPSOutput, dockerPSErr := i.runDockerCommandOnContextCapture(env, contextName, "ps", "-a", "--filter", projectFilter)
if dockerPSErr != nil {
diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker ps -a on runner context: %v", dockerPSErr))
dockerPSOutput = ""
}

dockerPSJSONOutput, dockerPSJSONErr := i.runDockerCommandOnContextCapture(
env,
contextName,
"ps",
"-a",
"--filter",
projectFilter,
"--format",
"{{json .}}",
)
if dockerPSJSONErr != nil {
diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker ps -a --format '{{json .}}' on runner context: %v", dockerPSJSONErr))
}

containers := []composePSContainer{}
if strings.TrimSpace(psOutput) != "" {
parsed, err := parseComposePSOutput(psOutput)
if dockerPSJSONErr == nil && strings.TrimSpace(dockerPSJSONOutput) != "" {
parsed, err := parseDockerPSOutput(dockerPSJSONOutput)
if err != nil {
diagnostics = append(diagnostics, fmt.Sprintf("Unable to parse docker compose ps output: %v", err))
diagnostics = append(diagnostics, fmt.Sprintf("Unable to parse docker ps output: %v", err))
} else {
containers = parsed
}
}

failed := selectFailedContainers(containers)
return renderComposeFailureReport(i, upArgs, upErr, containers, failed, psOutput, diagnostics)
return renderComposeFailureReport(i, upArgs, upErr, containers, failed, dockerPSOutput, diagnostics)
}

func parseComposePSOutput(raw string) ([]composePSContainer, error) {
func parseDockerPSOutput(raw string) ([]composePSContainer, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty output")
return []composePSContainer{}, nil
}

objects := []map[string]any{}
if strings.HasPrefix(raw, "[") {
if err := json.Unmarshal([]byte(raw), &objects); err != nil {
containers := []composePSContainer{}
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var row struct {
Names string `json:"Names"`
Status string `json:"Status"`
Labels string `json:"Labels"`
}
if err := json.Unmarshal([]byte(line), &row); err != nil {
return nil, err
}
} else {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var object map[string]any
if err := json.Unmarshal([]byte(line), &object); err != nil {
return nil, err
}
objects = append(objects, object)
state, health, exitCode := parseDockerPSStatus(row.Status)
service := dockerPSLabelValue(row.Labels, "com.docker.compose.service")
if service == "" {
service = strings.TrimSpace(row.Names)
}
}
if len(objects) == 0 {
return nil, fmt.Errorf("no containers found")
}

containers := make([]composePSContainer, 0, len(objects))
for _, object := range objects {
containers = append(containers, composePSContainer{
Service: composePSFieldString(object, "Service", "service"),
Name: composePSFieldString(object, "Name", "name"),
State: composePSFieldString(object, "State", "Status", "state"),
Health: composePSFieldString(object, "Health", "health"),
ExitCode: composePSFieldInt(object, "ExitCode", "exit_code"),
Service: service,
Name: strings.TrimSpace(row.Names),
State: state,
Health: health,
ExitCode: exitCode,
})
}
return containers, nil
}

func composePSFieldString(values map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := values[key]
if !ok {
func dockerPSLabelValue(labels string, key string) string {
for _, pair := range strings.Split(labels, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) != "" {
return strings.TrimSpace(typed)
}
case float64:
return strconv.Itoa(int(typed))
case json.Number:
return typed.String()
parts := strings.SplitN(pair, "=", 2)
if len(parts) != 2 {
continue
}
if strings.TrimSpace(parts[0]) == key {
return strings.TrimSpace(parts[1])
}
}
return ""
}

func composePSFieldInt(values map[string]any, keys ...string) int {
for _, key := range keys {
value, ok := values[key]
if !ok {
continue
}
switch typed := value.(type) {
case float64:
return int(typed)
case int:
return typed
case int64:
return int(typed)
case json.Number:
if parsed, err := typed.Int64(); err == nil {
return int(parsed)
}
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil {
return parsed
func parseDockerPSStatus(status string) (string, string, int) {
s := strings.ToLower(strings.TrimSpace(status))
state := "unknown"
switch {
case strings.HasPrefix(s, "up "):
state = "running"
case strings.HasPrefix(s, "exited "):
state = "exited"
case strings.HasPrefix(s, "created"):
state = "created"
case strings.HasPrefix(s, "restarting"):
state = "restarting"
case strings.HasPrefix(s, "paused"):
state = "paused"
case strings.HasPrefix(s, "dead"):
state = "dead"
case strings.HasPrefix(s, "removing"):
state = "removing"
}

health := ""
switch {
case strings.Contains(s, "(unhealthy)"):
health = "unhealthy"
case strings.Contains(s, "(healthy)"):
health = "healthy"
case strings.Contains(s, "(health: starting)"):
health = "starting"
}

exitCode := 0
if strings.HasPrefix(s, "exited (") {
rest := strings.TrimPrefix(s, "exited (")
if idx := strings.Index(rest, ")"); idx > 0 {
if code, err := strconv.Atoi(strings.TrimSpace(rest[:idx])); err == nil {
exitCode = code
}
}
}
return 0
return state, health, exitCode
}

func selectFailedContainers(containers []composePSContainer) []composePSContainer {
Expand Down Expand Up @@ -855,7 +893,7 @@ func renderComposeFailureReport(
}

if strings.TrimSpace(psOutput) != "" {
b.WriteString("### `docker compose ps -a`\n\n")
b.WriteString("### `docker ps -a` (runner context)\n\n")
b.WriteString("```text\n")
b.WriteString(truncateReportOutput(psOutput, composeFailureReportOutputLimit))
b.WriteString("\n```\n\n")
Expand Down
24 changes: 15 additions & 9 deletions internal/pullpreview/deploy_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,23 +244,26 @@ func TestInlinePreScriptLoadsLocalScriptContent(t *testing.T) {
}
}

func TestParseComposePSOutputJSON(t *testing.T) {
raw := `[
{"Service":"web","Name":"app-web-1","State":"exited","Health":"","ExitCode":1},
{"Service":"db","Name":"app-db-1","State":"running","Health":"unhealthy","ExitCode":0},
{"Service":"cache","Name":"app-cache-1","State":"running","Health":"","ExitCode":0}
]`
func TestParseDockerPSOutputJSONLines(t *testing.T) {
raw := strings.Join([]string{
`{"Names":"app-web-1","Status":"Exited (1) 5 seconds ago","Labels":"com.docker.compose.project=app,com.docker.compose.service=web"}`,
`{"Names":"app-db-1","Status":"Up 2 minutes (unhealthy)","Labels":"com.docker.compose.project=app,com.docker.compose.service=db"}`,
`{"Names":"app-cache-1","Status":"Up 2 minutes","Labels":"com.docker.compose.project=app,com.docker.compose.service=cache"}`,
}, "\n")

containers, err := parseComposePSOutput(raw)
containers, err := parseDockerPSOutput(raw)
if err != nil {
t.Fatalf("parseComposePSOutput() error: %v", err)
t.Fatalf("parseDockerPSOutput() error: %v", err)
}
if len(containers) != 3 {
t.Fatalf("expected 3 containers, got %d", len(containers))
}
if containers[0].Service != "web" || containers[0].Name != "app-web-1" || containers[0].ExitCode != 1 {
if containers[0].Service != "web" || containers[0].Name != "app-web-1" || containers[0].State != "exited" || containers[0].ExitCode != 1 {
t.Fatalf("unexpected first container: %#v", containers[0])
}
if containers[1].Service != "db" || containers[1].Health != "unhealthy" || containers[1].State != "running" {
t.Fatalf("unexpected second container: %#v", containers[1])
}
}

func TestSelectFailedContainers(t *testing.T) {
Expand Down Expand Up @@ -325,6 +328,9 @@ func TestRenderComposeFailureReportIncludesTroubleshooting(t *testing.T) {
if strings.Contains(report, "Last 20 log lines") {
t.Fatalf("did not expect report to include container logs, got:\n%s", report)
}
if strings.Contains(report, "docker compose ps -a") {
t.Fatalf("did not expect report to include docker compose ps command, got:\n%s", report)
}
}

func TestFailedContainerLogKeyPrefersContainerName(t *testing.T) {
Expand Down
Loading
Loading