a73x

internal/smoke/coverage.go

Ref:   Size: 5.3 KiB   History

package smoke

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

// covdataMergeArgs builds the `go tool covdata merge` argument list that unions
// the per-binary coverage directories in inputDirs into outDir. Pure so the
// command assembly is unit-testable without running the toolchain.
func covdataMergeArgs(inputDirs []string, outDir string) []string {
	return []string{"tool", "covdata", "merge",
		"-i=" + strings.Join(inputDirs, ","),
		"-o=" + outDir}
}

// covdataTextfmtArgs builds the `go tool covdata textfmt` argument list that
// renders the merged coverage in inDir as a textual profile at outFile.
func covdataTextfmtArgs(inDir, outFile string) []string {
	return []string{"tool", "covdata", "textfmt",
		"-i=" + inDir,
		"-o=" + outFile}
}

// collectCoverage snapshots live coverage from the running server and agent,
// pulls both GOCOVERDIRs together, and merges them into cfg.CoverOut, printing
// the total. It needs both cfg.ServerGocoverdir and cfg.AgentGocoverdir; when
// either is empty it is a no-op with an explanatory note. Any failure here is
// the caller's to treat as non-fatal — the boot gate is the authoritative check.
func collectCoverage(ctx context.Context, cfg Config) error {
	if cfg.ServerGocoverdir == "" || cfg.AgentGocoverdir == "" {
		fmt.Println("coverage: SERVER_GOCOVERDIR/AGENT_GOCOVERDIR not both set — skipping collection")
		return nil
	}

	// 1. Snapshot: SIGUSR1 makes each covsnap handler flush counters into its
	// GOCOVERDIR. The server is local; the agent runs as root on the remote, so
	// its pkill needs sudo over SSH.
	if err := runCmd(exec.CommandContext(ctx, "pkill", "-USR1", "-x", "eitri-server")); err != nil {
		fmt.Fprintln(os.Stderr, "coverage: signalling local eitri-server:", err)
	}
	if err := runCmd(sshCommand(ctx, cfg, "sudo pkill -USR1 -x eitri-agent")); err != nil {
		fmt.Fprintln(os.Stderr, "coverage: signalling remote eitri-agent:", err)
	}
	// Let both handlers finish writing before we read their dirs.
	time.Sleep(2 * time.Second)

	// 2. Collect: the server dir is local; stream the agent's over SSH via a
	// sudo tar pipe (its files are root-owned, so a plain scp can't read them).
	agentDir, err := os.MkdirTemp("", "eitri-smoke-agentcov-")
	if err != nil {
		return fmt.Errorf("temp dir for agent coverage: %w", err)
	}
	defer os.RemoveAll(agentDir)
	if err := pullAgentCoverage(ctx, cfg, agentDir); err != nil {
		return err
	}

	// 3. Merge both binaries' data, render a textual profile, and read the total.
	// Start from a clean CoverOut: covdata refuses to read a directory that
	// mixes covermodes, so a stale profile from an earlier deploy would clash
	// with this run's data.
	if err := os.RemoveAll(cfg.CoverOut); err != nil {
		return fmt.Errorf("clean cover out %q: %w", cfg.CoverOut, err)
	}
	if err := os.MkdirAll(cfg.CoverOut, 0o755); err != nil {
		return fmt.Errorf("mkdir cover out %q: %w", cfg.CoverOut, err)
	}
	if err := runCmd(exec.CommandContext(ctx, "go", covdataMergeArgs([]string{cfg.ServerGocoverdir, agentDir}, cfg.CoverOut)...)); err != nil {
		return fmt.Errorf("covdata merge: %w", err)
	}
	textOut := filepath.Join(cfg.CoverOut, "coverage.txt")
	if err := runCmd(exec.CommandContext(ctx, "go", covdataTextfmtArgs(cfg.CoverOut, textOut)...)); err != nil {
		return fmt.Errorf("covdata textfmt: %w", err)
	}

	total, err := coverageTotal(ctx, textOut)
	if err != nil {
		return err
	}
	fmt.Printf("coverage: %s (profile: %s)\n", total, textOut)
	return nil
}

// pullAgentCoverage streams the agent's GOCOVERDIR contents into destDir over a
// sudo tar pipe (the coverage files are root-owned on the remote).
func pullAgentCoverage(ctx context.Context, cfg Config, destDir string) error {
	tarball, err := sshCommand(ctx, cfg, "sudo tar -C '"+cfg.AgentGocoverdir+"' -cf - .").Output()
	if err != nil {
		return fmt.Errorf("stream agent coverage over ssh: %w", err)
	}
	untar := exec.CommandContext(ctx, "tar", "-C", destDir, "-xf", "-")
	untar.Stdin = bytes.NewReader(tarball)
	if err := runCmd(untar); err != nil {
		return fmt.Errorf("extract agent coverage: %w", err)
	}
	return nil
}

// coverageTotal returns the final "total:" line of `go tool cover -func`.
func coverageTotal(ctx context.Context, profile string) (string, error) {
	out, err := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+profile).Output()
	if err != nil {
		return "", fmt.Errorf("go tool cover -func: %w", err)
	}
	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
	return lines[len(lines)-1], nil
}

// sshCommand builds the ssh invocation used to reach the agent host, matching
// the flags the deploy boot-gate uses to reach the agent host.
func sshCommand(ctx context.Context, cfg Config, remoteCmd string) *exec.Cmd {
	return exec.CommandContext(ctx, "ssh",
		"-p", strconv.Itoa(cfg.AgentPort),
		"-o", "BatchMode=yes",
		"-o", "ConnectTimeout=10",
		cfg.AgentUserHost, remoteCmd)
}

// runCmd runs cmd, capturing stderr so a failure carries the command's own
// diagnostic rather than a bare exit code.
func runCmd(cmd *exec.Cmd) error {
	var errb bytes.Buffer
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		if msg := strings.TrimSpace(errb.String()); msg != "" {
			return fmt.Errorf("%s: %w: %s", cmd.Args[0], err, msg)
		}
		return fmt.Errorf("%s: %w", cmd.Args[0], err)
	}
	return nil
}