You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
5.1 KiB
182 lines
5.1 KiB
package testrunner
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// RunManifest records the identity and provenance of a single test run.
|
|
// Written to manifest.json in the run bundle directory.
|
|
type RunManifest struct {
|
|
RunID string `json:"run_id"`
|
|
StartedAt string `json:"started_at"`
|
|
FinishedAt string `json:"finished_at,omitempty"`
|
|
ScenarioName string `json:"scenario_name"`
|
|
ScenarioFile string `json:"scenario_file"`
|
|
ScenarioSHA256 string `json:"scenario_sha256"`
|
|
RunnerVersion string `json:"runner_version,omitempty"`
|
|
GitSHA string `json:"git_sha,omitempty"`
|
|
Host string `json:"host,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
CommandLine string `json:"command_line,omitempty"`
|
|
}
|
|
|
|
// RunBundle manages the per-run output directory.
|
|
type RunBundle struct {
|
|
Dir string // absolute path to the run directory
|
|
Manifest RunManifest
|
|
scenarioData []byte // frozen copy of the input YAML
|
|
}
|
|
|
|
// CreateRunBundle creates a timestamped run directory under resultsRoot.
|
|
// Directory name: YYYYMMDD-HHMMSS-<short-id>
|
|
// Creates: manifest.json (partial), scenario.yaml (frozen copy).
|
|
func CreateRunBundle(resultsRoot, scenarioFile string, cmdLine []string) (*RunBundle, error) {
|
|
now := time.Now()
|
|
|
|
// Read and hash the scenario file.
|
|
scenarioData, err := os.ReadFile(scenarioFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read scenario: %w", err)
|
|
}
|
|
h := sha256.Sum256(scenarioData)
|
|
scenarioHash := hex.EncodeToString(h[:])
|
|
|
|
// Parse scenario name from the file (with correct base dir for includes).
|
|
scenario, err := ParseWithBase(scenarioData, filepath.Dir(scenarioFile))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse scenario for manifest: %w", err)
|
|
}
|
|
|
|
// Generate run ID: timestamp + short hash of (scenario + time).
|
|
ts := now.Format("20060102-150405")
|
|
idSeed := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", scenarioFile, now.UnixNano())))
|
|
shortID := hex.EncodeToString(idSeed[:2]) // 4 hex chars
|
|
runID := ts + "-" + shortID
|
|
|
|
// Create directory.
|
|
runDir := filepath.Join(resultsRoot, runID)
|
|
if err := os.MkdirAll(runDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("create run dir: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(runDir, "artifacts"), 0755); err != nil {
|
|
return nil, fmt.Errorf("create artifacts dir: %w", err)
|
|
}
|
|
|
|
// Build manifest.
|
|
manifest := RunManifest{
|
|
RunID: runID,
|
|
StartedAt: now.UTC().Format(time.RFC3339),
|
|
ScenarioName: scenario.Name,
|
|
ScenarioFile: scenarioFile,
|
|
ScenarioSHA256: scenarioHash,
|
|
RunnerVersion: Version(),
|
|
GitSHA: gitSHA(),
|
|
Host: hostname(),
|
|
CommandLine: strings.Join(cmdLine, " "),
|
|
}
|
|
|
|
b := &RunBundle{
|
|
Dir: runDir,
|
|
Manifest: manifest,
|
|
scenarioData: scenarioData,
|
|
}
|
|
|
|
// Write frozen scenario copy.
|
|
scenarioDst := filepath.Join(runDir, "scenario.yaml")
|
|
if err := os.WriteFile(scenarioDst, scenarioData, 0644); err != nil {
|
|
return nil, fmt.Errorf("write scenario copy: %w", err)
|
|
}
|
|
|
|
// Write initial manifest (will be updated at finalize).
|
|
if err := b.writeManifest(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
// Finalize writes the final result files into the run bundle.
|
|
func (b *RunBundle) Finalize(result *ScenarioResult) error {
|
|
// Update manifest with final status and time.
|
|
b.Manifest.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
|
b.Manifest.Status = string(result.Status)
|
|
if err := b.writeManifest(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write result.json.
|
|
if err := WriteJSON(result, filepath.Join(b.Dir, "result.json")); err != nil {
|
|
return fmt.Errorf("write result.json: %w", err)
|
|
}
|
|
|
|
// Write result.xml (JUnit).
|
|
if err := WriteJUnitXML(result, filepath.Join(b.Dir, "result.xml")); err != nil {
|
|
return fmt.Errorf("write result.xml: %w", err)
|
|
}
|
|
|
|
// Write result.html.
|
|
if err := WriteHTMLReport(result, filepath.Join(b.Dir, "result.html")); err != nil {
|
|
return fmt.Errorf("write result.html: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ArtifactsDir returns the path to the artifacts subdirectory.
|
|
func (b *RunBundle) ArtifactsDir() string {
|
|
return filepath.Join(b.Dir, "artifacts")
|
|
}
|
|
|
|
func (b *RunBundle) writeManifest() error {
|
|
data, err := json.MarshalIndent(b.Manifest, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal manifest: %w", err)
|
|
}
|
|
return os.WriteFile(filepath.Join(b.Dir, "manifest.json"), data, 0644)
|
|
}
|
|
|
|
// CopyArtifact copies a file into the run bundle's artifacts directory.
|
|
func (b *RunBundle) CopyArtifact(src, name string) error {
|
|
dst := filepath.Join(b.ArtifactsDir(), name)
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
func hostname() string {
|
|
h, _ := os.Hostname()
|
|
return h
|
|
}
|
|
|
|
func gitSHA() string {
|
|
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// Version returns the runner version. Set at build time via ldflags.
|
|
var version = "dev"
|
|
|
|
func Version() string {
|
|
return version
|
|
}
|