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
}