mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
test(agentcompat): add scenario CLI wiring
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/scenario"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_ArtifactPublicationReplacesPublicFileAndFinalSymlink(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(*testing.T, string, string)
|
||||||
|
}{
|
||||||
|
{"public file", func(t *testing.T, path, _ string) {
|
||||||
|
if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write old file: %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"final symlink", func(t *testing.T, path, sentinel string) {
|
||||||
|
if err := os.Symlink(sentinel, path); err != nil {
|
||||||
|
t.Fatalf("symlink final path: %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "cleanup.json")
|
||||||
|
sentinel := filepath.Join(t.TempDir(), "sentinel")
|
||||||
|
if err := os.WriteFile(sentinel, []byte("unchanged"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write sentinel: %v", err)
|
||||||
|
}
|
||||||
|
test.setup(t, path, sentinel)
|
||||||
|
if err := writeJSONArtifact(dir, "cleanup.json", map[string]bool{"passed": true}); err != nil {
|
||||||
|
t.Fatalf("publish artifact: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lstat artifact: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("artifact mode=%v", info.Mode())
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(sentinel)
|
||||||
|
if err != nil || string(content) != "unchanged" {
|
||||||
|
t.Fatalf("outside sentinel changed: content=%q err=%v", content, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_InterruptedScenarioPublicationLeavesAtomicInvalidDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
config := testCLIConfig(t, contract.ScenarioTransfer100MiB, contract.FaultTransferHash)
|
||||||
|
paths, err := contract.NewPaths(config.Paths.NezhaSource().String(), config.Paths.AgentSource().String(), dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("paths: %v", err)
|
||||||
|
}
|
||||||
|
config.Paths = paths
|
||||||
|
if err := writeMetadata(t.Context(), config, time.Now()); err != nil {
|
||||||
|
t.Fatalf("metadata: %v", err)
|
||||||
|
}
|
||||||
|
previous := scenarioArtifactPublished
|
||||||
|
scenarioArtifactPublished = func(name string) error {
|
||||||
|
if name == "results.json" {
|
||||||
|
return errors.New("injected publication interruption")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { scenarioArtifactPublished = previous })
|
||||||
|
output := scenarioExecutionOutput{Result: scenario.Result{Name: contract.ScenarioTransfer100MiB, Passed: false, CleanupOK: true, Error: "transfer scenario: injected hash mismatch"}, Transfer: &scenario.TransferEvidence{WarmupUploadBytes: 65536, WarmupDownloadBytes: 65536, WarmupSHA256: "abc", WarmupDuration: time.Nanosecond, WarmupDeadlineRemaining: time.Second, WarmupQuiescent: true, OutsideRootSentinelsUnchanged: true}}
|
||||||
|
if err := writeScenarioEvidence(config, output, time.Now()); err == nil {
|
||||||
|
t.Fatal("publication interruption accepted")
|
||||||
|
}
|
||||||
|
if err := evidence.ValidateDirectory(dir); err == nil {
|
||||||
|
t.Fatal("partial evidence directory validated")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "results.json"))
|
||||||
|
if !errors.Is(err, os.ErrNotExist) || len(data) != 0 {
|
||||||
|
t.Fatalf("interrupted final file published: bytes=%d err=%v", len(data), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_PrivateArtifactJoinsPrimaryAndCleanupErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "results.json")
|
||||||
|
primaryErr := errors.New("publication hook failed")
|
||||||
|
removeErr := errors.New("temporary removal failed")
|
||||||
|
previousClose := privateArtifactClose
|
||||||
|
previousRemove := privateArtifactRemove
|
||||||
|
closeCalls := 0
|
||||||
|
privateArtifactClose = func(file *os.File) error {
|
||||||
|
closeCalls++
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
privateArtifactRemove = func(string) error { return removeErr }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
privateArtifactClose = previousClose
|
||||||
|
privateArtifactRemove = previousRemove
|
||||||
|
})
|
||||||
|
|
||||||
|
err := writePrivateArtifactWithSeam(path, []byte("payload"), func() error { return primaryErr })
|
||||||
|
if !errors.Is(err, primaryErr) || !errors.Is(err, removeErr) {
|
||||||
|
t.Fatalf("publication error=%v, want primary and removal errors", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrClosed) || closeCalls != 1 {
|
||||||
|
t.Fatalf("successful close repeated: calls=%d err=%v", closeCalls, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_PrivateArtifactJoinsCloseAndRemoveErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "results.json")
|
||||||
|
closeErr := errors.New("temporary close failed")
|
||||||
|
removeErr := errors.New("temporary removal failed")
|
||||||
|
previousClose := privateArtifactClose
|
||||||
|
previousRemove := privateArtifactRemove
|
||||||
|
closeCalls := 0
|
||||||
|
privateArtifactClose = func(file *os.File) error {
|
||||||
|
closeCalls++
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
privateArtifactRemove = func(string) error { return removeErr }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
privateArtifactClose = previousClose
|
||||||
|
privateArtifactRemove = previousRemove
|
||||||
|
})
|
||||||
|
|
||||||
|
err := writePrivateArtifactWithSeam(path, []byte("payload"), func() error { return nil })
|
||||||
|
if !errors.Is(err, closeErr) || !errors.Is(err, removeErr) {
|
||||||
|
t.Fatalf("publication error=%v, want close and removal errors", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrClosed) || closeCalls != 1 {
|
||||||
|
t.Fatalf("close failure retried: calls=%d err=%v", closeCalls, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_PrivateArtifactRemovesTemporaryFileAfterHookFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "results.json")
|
||||||
|
err := writePrivateArtifactWithSeam(path, []byte("payload"), func() error {
|
||||||
|
return errors.New("publication hook failed")
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("publication hook failure accepted")
|
||||||
|
}
|
||||||
|
entries, readErr := os.ReadDir(dir)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read artifact directory: %v", readErr)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("temporary artifact survived hook failure: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scenarioFlags []contract.Scenario
|
||||||
|
|
||||||
|
func (scenarios *scenarioFlags) String() string {
|
||||||
|
values := make([]string, 0, len(*scenarios))
|
||||||
|
for _, scenario := range *scenarios {
|
||||||
|
values = append(values, scenario.String())
|
||||||
|
}
|
||||||
|
return strings.Join(values, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scenarios *scenarioFlags) Set(value string) error {
|
||||||
|
scenario, err := contract.NewScenario(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*scenarios = append(*scenarios, scenario)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliConfig struct {
|
||||||
|
Paths contract.Paths
|
||||||
|
Profile contract.Profile
|
||||||
|
Seed contract.Seed
|
||||||
|
Scenarios scenarioFlags
|
||||||
|
Fault contract.Fault
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags(args []string, stderr io.Writer) (cliConfig, error) {
|
||||||
|
flags := flag.NewFlagSet("agentcompat", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
nezhaSource := flags.String("nezha-source", "", "Nezha source directory")
|
||||||
|
agentSource := flags.String("agent-source", "", "Agent source directory")
|
||||||
|
profileName := flags.String("profile", "", "compatibility profile")
|
||||||
|
resultsDir := flags.String("results-dir", "", "evidence output directory")
|
||||||
|
seedValue := flags.String("seed", "0x4e5a4841", "deterministic seed")
|
||||||
|
faultName := flags.String("fault", "", "named fault injection")
|
||||||
|
var scenarios scenarioFlags
|
||||||
|
flags.Var(&scenarios, "scenario", "run only a named scenario; omit for the complete profile")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return cliConfig{}, errors.New("invalid command-line arguments")
|
||||||
|
}
|
||||||
|
paths, err := contract.NewPaths(*nezhaSource, *agentSource, *resultsDir)
|
||||||
|
if err != nil {
|
||||||
|
return cliConfig{}, err
|
||||||
|
}
|
||||||
|
if err := prepareResultsDirBeforeParse(paths.ResultsDir().String()); err != nil {
|
||||||
|
return cliConfig{}, err
|
||||||
|
}
|
||||||
|
profile, err := contract.ProfileByName(*profileName)
|
||||||
|
if err != nil {
|
||||||
|
return cliConfig{}, err
|
||||||
|
}
|
||||||
|
seed, err := contract.ParseSeed(*seedValue)
|
||||||
|
if err != nil {
|
||||||
|
return cliConfig{}, err
|
||||||
|
}
|
||||||
|
fault := contract.Fault{}
|
||||||
|
if *faultName != "" {
|
||||||
|
fault, err = contract.NewFault(*faultName)
|
||||||
|
if err != nil {
|
||||||
|
return cliConfig{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cliConfig{Paths: paths, Profile: profile, Seed: seed, Scenarios: scenarios, Fault: fault}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareResultsDir(resultsDir string) error {
|
||||||
|
return prepareEvidenceArtifacts(resultsDir, evidence.FixedEvidenceFiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareResultsDirBeforeParse(resultsDir string) error {
|
||||||
|
return prepareEvidenceArtifacts(resultsDir, evidence.FixedEvidenceFiles()[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareEvidenceArtifacts(resultsDir string, artifactNames []string) error {
|
||||||
|
if info, err := os.Lstat(resultsDir); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return errors.New("results directory must not be a symbolic link")
|
||||||
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("inspect results directory: %w", err)
|
||||||
|
}
|
||||||
|
for _, name := range artifactNames {
|
||||||
|
if err := os.Remove(filepath.Join(resultsDir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove previous artifact %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(filepath.Join(resultsDir, "agents")); err != nil {
|
||||||
|
return fmt.Errorf("remove previous agent logs: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/scenario"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_SelectsTransferAndReconnectWithTypedEvidence(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fault string
|
||||||
|
}{
|
||||||
|
{contract.ScenarioTransfer100MiB, ""},
|
||||||
|
{contract.ScenarioTransfer100MiB, contract.FaultTransferHash},
|
||||||
|
{contract.ScenarioReconnect, ""},
|
||||||
|
{contract.ScenarioReconnect, contract.FaultDashboardExit},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name+"/"+test.fault, func(t *testing.T) {
|
||||||
|
config := testCLIConfig(t, test.name, test.fault)
|
||||||
|
runnerErr := errors.New("dedicated runner sentinel")
|
||||||
|
wantResult := scenario.Result{Name: test.name, Passed: false, CleanupOK: true, Error: runnerErr.Error(), Assertions: []scenario.Assertion{{Name: "typed dispatch", Passed: false, Details: test.fault}}}
|
||||||
|
wantTransfer := scenario.TransferEvidence{WarmupUploadBytes: 65536, WarmupDownloadBytes: 65536, WarmupSHA256: "warmup", WarmupDuration: time.Second, WarmupDeadlineRemaining: 2 * time.Second, WarmupQuiescent: true, UploadBytes: contract.TransferBytes, DownloadBytes: contract.TransferBytes, UploadSHA256: "transfer-hash", DownloadSHA256: "transfer-hash", UploadChunks: 3, DownloadChunks: 4, UploadDuration: 5 * time.Second, DownloadDuration: 6 * time.Second, RetainedHeapBytes: 7, Mode: "0640", CreateDirs: true, UploadReplayRejected: true, DownloadReplayRejected: true, OversizeRejected: true, OutsideRootSentinelsUnchanged: true}
|
||||||
|
wantReconnect := completeReconnectDispatchEvidence(t)
|
||||||
|
previousTransfer := runTransferScenario
|
||||||
|
previousReconnect := runReconnectScenario
|
||||||
|
var receivedTransfer *scenario.TransferInput
|
||||||
|
var receivedReconnect *scenario.ReconnectInput
|
||||||
|
runTransferScenario = func(_ context.Context, input scenario.TransferInput) (scenario.Result, scenario.TransferEvidence, error) {
|
||||||
|
receivedTransfer = &input
|
||||||
|
return wantResult, wantTransfer, runnerErr
|
||||||
|
}
|
||||||
|
runReconnectScenario = func(_ context.Context, input scenario.ReconnectInput) (scenario.Result, scenario.ReconnectEvidence, error) {
|
||||||
|
receivedReconnect = &input
|
||||||
|
return wantResult, wantReconnect, runnerErr
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
runTransferScenario = previousTransfer
|
||||||
|
runReconnectScenario = previousReconnect
|
||||||
|
})
|
||||||
|
|
||||||
|
execution, err := selectScenarioExecution(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select execution: %v", err)
|
||||||
|
}
|
||||||
|
output, runErr := execution.run(context.Background())
|
||||||
|
if !errors.Is(runErr, runnerErr) {
|
||||||
|
t.Fatalf("runner error=%v, want sentinel", runErr)
|
||||||
|
}
|
||||||
|
if err := output.Validate(); err != nil {
|
||||||
|
t.Fatalf("validate typed output: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(output.Result, wantResult) {
|
||||||
|
t.Fatalf("result=%#v, want %#v", output.Result, wantResult)
|
||||||
|
}
|
||||||
|
if test.name == contract.ScenarioTransfer100MiB {
|
||||||
|
wantInput := scenario.TransferInput{Paths: config.Paths, Fault: config.Fault}
|
||||||
|
if receivedTransfer == nil || *receivedTransfer != wantInput || receivedReconnect != nil {
|
||||||
|
t.Fatalf("transfer inputs: received=%#v reconnect=%#v want=%#v", receivedTransfer, receivedReconnect, wantInput)
|
||||||
|
}
|
||||||
|
if output.Transfer == nil || !reflect.DeepEqual(*output.Transfer, wantTransfer) || output.Reconnect != nil {
|
||||||
|
t.Fatalf("transfer evidence=%#v reconnect=%#v", output.Transfer, output.Reconnect)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wantInput := scenario.ReconnectInput{Paths: config.Paths, DashboardFault: config.Fault.String()}
|
||||||
|
if receivedReconnect == nil || *receivedReconnect != wantInput || receivedTransfer != nil {
|
||||||
|
t.Fatalf("reconnect inputs: received=%#v transfer=%#v want=%#v", receivedReconnect, receivedTransfer, wantInput)
|
||||||
|
}
|
||||||
|
if output.Reconnect == nil || !reflect.DeepEqual(*output.Reconnect, wantReconnect) || output.Transfer != nil {
|
||||||
|
t.Fatalf("reconnect evidence=%#v transfer=%#v", output.Reconnect, output.Transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_RejectsUnsupportedScenarioFaultPairsBeforeRunner(t *testing.T) {
|
||||||
|
tests := []struct{ scenario, fault string }{
|
||||||
|
{contract.ScenarioTransfer100MiB, contract.FaultDashboardExit},
|
||||||
|
{contract.ScenarioReconnect, contract.FaultTransferHash},
|
||||||
|
{contract.ScenarioMCPFilesystem, contract.FaultAgentBadSecret},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
called := false
|
||||||
|
previousTransfer := runTransferScenario
|
||||||
|
runTransferScenario = func(context.Context, scenario.TransferInput) (scenario.Result, scenario.TransferEvidence, error) {
|
||||||
|
called = true
|
||||||
|
return scenario.Result{}, scenario.TransferEvidence{}, errors.New("unexpected")
|
||||||
|
}
|
||||||
|
_, err := selectScenarioExecution(testCLIConfig(t, test.scenario, test.fault))
|
||||||
|
runTransferScenario = previousTransfer
|
||||||
|
if err == nil || called {
|
||||||
|
t.Fatalf("unsupported pair started runner: scenario=%q fault=%q err=%v", test.scenario, test.fault, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_WritesDedicatedArtifactWithPrivateMode(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
output := scenarioExecutionOutput{
|
||||||
|
Result: scenario.Result{Name: contract.ScenarioTransfer100MiB, Passed: false, CleanupOK: true, Error: "transfer scenario: injected hash mismatch"},
|
||||||
|
Transfer: &scenario.TransferEvidence{WarmupUploadBytes: 65536, WarmupDownloadBytes: 65536, WarmupSHA256: "abc", WarmupDuration: time.Nanosecond, WarmupDeadlineRemaining: time.Second, WarmupQuiescent: true, OutsideRootSentinelsUnchanged: true},
|
||||||
|
}
|
||||||
|
config := testCLIConfig(t, contract.ScenarioTransfer100MiB, contract.FaultTransferHash)
|
||||||
|
paths, err := contract.NewPaths(config.Paths.NezhaSource().String(), config.Paths.AgentSource().String(), resultsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("paths: %v", err)
|
||||||
|
}
|
||||||
|
config.Paths = paths
|
||||||
|
if err := writeScenarioEvidence(config, output, time.Now()); err != nil {
|
||||||
|
t.Fatalf("write evidence: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(resultsDir, "transfer.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat transfer evidence: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("transfer evidence mode=%o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCLIConfig(t *testing.T, scenarioName, faultName string) cliConfig {
|
||||||
|
t.Helper()
|
||||||
|
paths, err := contract.NewPaths("/src/nezha", "/src/agent", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("paths: %v", err)
|
||||||
|
}
|
||||||
|
profile, err := contract.ProfileByName("pr-full")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("profile: %v", err)
|
||||||
|
}
|
||||||
|
scenarioValue, err := contract.NewScenario(scenarioName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scenario: %v", err)
|
||||||
|
}
|
||||||
|
fault := contract.Fault{}
|
||||||
|
if faultName != "" {
|
||||||
|
fault, err = contract.NewFault(faultName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fault: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cliConfig{Paths: paths, Profile: profile, Seed: contract.DefaultSeed, Scenarios: scenarioFlags{scenarioValue}, Fault: fault}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, now time.Time) error {
|
||||||
|
config, err := parseFlags(args, stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeMetadata(ctx, config, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(config.Scenarios) == 1 && config.Scenarios[0].String() == contract.ScenarioMetadata && config.Fault.IsZero() {
|
||||||
|
fmt.Fprintf(stdout, "metadata written for profile %s\n", config.Profile.Name())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
execution, err := selectScenarioExecution(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scenarioContext, cancel := context.WithTimeout(ctx, config.Profile.SuiteDeadline())
|
||||||
|
defer cancel()
|
||||||
|
output, runErr := execution.run(scenarioContext)
|
||||||
|
if err := writeScenarioEvidence(config, output, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := evidence.ValidateDirectory(config.Paths.ResultsDir().String()); err != nil {
|
||||||
|
return fmt.Errorf("validate scenario evidence: %w", err)
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "scenario %s passed for profile %s\n", execution.name, config.Profile.Name())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, stdout io.Writer, stderr io.Writer, now time.Time) error {
|
||||||
|
return runContext(context.Background(), args, stdout, stderr, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := runContext(ctx, os.Args[1:], os.Stdout, os.Stderr, time.Now().UTC()); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "agentcompat: %s\n", evidence.Redact(err.Error()))
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/scenario"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_ParsesTypedFlagsAndWritesMetadata(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := run([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", resultsDir, "--seed", "0x4e5a4841", "--scenario", "metadata"}, &stdout, &stderr, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run CLI: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(resultsDir, "metadata.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read metadata: %v", err)
|
||||||
|
}
|
||||||
|
var metadata struct {
|
||||||
|
Profile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"profile"`
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
t.Fatalf("parse metadata: %v", err)
|
||||||
|
}
|
||||||
|
if metadata.Profile.Name != "pr-full" || metadata.Seed != "0x4e5a4841" || !strings.Contains(stdout.String(), "metadata written") {
|
||||||
|
t.Fatalf("unexpected metadata-only output: %#v %q", metadata, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_MetadataEvidenceValidatesAsCurrentMetadataProfile(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := run([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", resultsDir, "--scenario", "metadata"}, &stdout, &stderr, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run metadata CLI: %v", err)
|
||||||
|
}
|
||||||
|
if err := evidence.ValidateDirectory(resultsDir); err != nil {
|
||||||
|
t.Fatalf("validate metadata evidence: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_RejectsInvalidProfileAndSeedWithoutSecretEcho(t *testing.T) {
|
||||||
|
for name, args := range map[string][]string{
|
||||||
|
"profile": {"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "private-profile-secret", "--results-dir", t.TempDir()},
|
||||||
|
"seed": {"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", t.TempDir(), "--seed", "not-a-seed-secret"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := run(args, &stdout, &stderr, time.Now())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid CLI input accepted")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "not-a-seed-secret") || strings.Contains(err.Error(), "private-profile-secret") {
|
||||||
|
t.Fatal("invalid input echoed in error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_RejectsMissingPaths(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := run([]string{"--profile", "pr-full", "--scenario", "metadata"}, &stdout, &stderr, time.Now())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "--nezha-source") {
|
||||||
|
t.Fatalf("missing source paths were not rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_ParsesRepeatableScenariosAndFault(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
config, err := parseFlags([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "soak", "--results-dir", "/tmp/results", "--scenario", "metadata", "--scenario", "transfer-100mib", "--fault", "transfer-hash"}, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse flags: %v", err)
|
||||||
|
}
|
||||||
|
if len(config.Scenarios) != 2 || config.Scenarios[0].String() != "metadata" || config.Scenarios[1].String() != "transfer-100mib" || config.Fault.String() != "transfer-hash" {
|
||||||
|
t.Fatalf("unexpected typed flags: %#v", config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_WritesMetadataBeforeRejectingUnsupportedRuntime(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := run([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", resultsDir, "--scenario", "future-scenario"}, &stdout, &stderr, time.Now())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("unimplemented runtime reported success")
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(resultsDir, "metadata.json")); statErr != nil {
|
||||||
|
t.Fatalf("metadata was not written before runtime rejection: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_RecognizesOnlyMCPFilesystemRuntimeScenario(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
config, err := parseFlags([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", t.TempDir(), "--scenario", "mcp-filesystem"}, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse mcp-filesystem flags: %v", err)
|
||||||
|
}
|
||||||
|
execution, err := selectScenarioExecution(config)
|
||||||
|
if err != nil || execution.name != "mcp-filesystem" {
|
||||||
|
t.Fatalf("mcp-filesystem runtime registration: name=%q err=%v", execution.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Scenarios = append(config.Scenarios, config.Scenarios[0])
|
||||||
|
if _, err := selectScenarioExecution(config); err == nil || !strings.Contains(err.Error(), "exactly one") {
|
||||||
|
t.Fatalf("multi-scenario runtime was unexpectedly accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_SelectsTerminalRuntimeScenario(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
config, err := parseFlags([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", t.TempDir(), "--scenario", "terminal"}, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse terminal flags: %v", err)
|
||||||
|
}
|
||||||
|
previous := runTerminalScenario
|
||||||
|
var received scenario.TerminalInput
|
||||||
|
runTerminalScenario = func(_ context.Context, input scenario.TerminalInput) (scenario.Result, error) {
|
||||||
|
received = input
|
||||||
|
return scenario.Result{Name: "terminal", Passed: true}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { runTerminalScenario = previous })
|
||||||
|
|
||||||
|
execution, err := selectScenarioExecution(config)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select terminal scenario: %v", err)
|
||||||
|
}
|
||||||
|
if execution.name != "terminal" || execution.run == nil {
|
||||||
|
t.Fatalf("unexpected terminal execution: %#v", execution)
|
||||||
|
}
|
||||||
|
if _, err := execution.run(context.Background()); err != nil {
|
||||||
|
t.Fatalf("run terminal execution: %v", err)
|
||||||
|
}
|
||||||
|
if received.Paths.NezhaSource().String() != "/src/nezha" || received.Paths.AgentSource().String() != "/src/agent" || !received.Fault.IsZero() {
|
||||||
|
t.Fatalf("terminal input was not forwarded: %#v", received)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_MetadataWriteReplacesSymlinkWithoutChangingTarget(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
target := filepath.Join(t.TempDir(), "target")
|
||||||
|
if err := os.WriteFile(target, []byte("sentinel"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write target: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(resultsDir, "metadata.json")); err != nil {
|
||||||
|
t.Fatalf("create metadata symlink: %v", err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := run([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", resultsDir, "--scenario", "metadata"}, &stdout, &stderr, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replace metadata symlink: %v", err)
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(target)
|
||||||
|
if readErr != nil || string(data) != "sentinel" {
|
||||||
|
t.Fatalf("symlink target changed: %q %v", data, readErr)
|
||||||
|
}
|
||||||
|
info, statErr := os.Lstat(filepath.Join(resultsDir, "metadata.json"))
|
||||||
|
if statErr != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("metadata replacement mode=%v err=%v", info, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_MetadataWriteUsesPrivatePermissions(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
resultsDir := filepath.Join(root, "results")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if err := run([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", resultsDir, "--scenario", "metadata"}, &stdout, &stderr, time.Now()); err != nil {
|
||||||
|
t.Fatalf("write metadata: %v", err)
|
||||||
|
}
|
||||||
|
directoryInfo, err := os.Stat(resultsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat results directory: %v", err)
|
||||||
|
}
|
||||||
|
fileInfo, err := os.Stat(filepath.Join(resultsDir, "metadata.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat metadata: %v", err)
|
||||||
|
}
|
||||||
|
if directoryInfo.Mode().Perm() != 0o700 || fileInfo.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("unexpected permissions: directory=%o file=%o", directoryInfo.Mode().Perm(), fileInfo.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
var metadataArtifactReady = func(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func writeMetadata(ctx context.Context, config cliConfig, now time.Time) error {
|
||||||
|
resultsDir := config.Paths.ResultsDir().String()
|
||||||
|
if info, err := os.Lstat(resultsDir); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return errors.New("results directory must not be a symbolic link")
|
||||||
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("inspect results directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(resultsDir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create results directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(resultsDir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("secure results directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := prepareResultsDir(resultsDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
metadata, err := evidence.NewMetadata(evidence.MetadataInput{Profile: config.Profile, Seed: config.Seed, Paths: config.Paths, ResourceBudget: contract.DefaultResourceBudget(), Scenarios: config.Scenarios, Fault: config.Fault, StartedAt: now, EvidenceFiles: evidence.EvidenceFiles()})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build metadata: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(metadata, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal metadata: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(resultsDir, "metadata.json")
|
||||||
|
return writePrivateArtifactWithSeam(path, data, func() error { return metadataArtifactReady(ctx) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_MetadataCancellationRemovesTemporaryArtifact(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
config := testCLIConfig(t, contract.ScenarioMetadata, "")
|
||||||
|
paths, err := contract.NewPaths(config.Paths.NezhaSource().String(), config.Paths.AgentSource().String(), resultsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("paths: %v", err)
|
||||||
|
}
|
||||||
|
config.Paths = paths
|
||||||
|
ready := make(chan struct{})
|
||||||
|
previous := metadataArtifactReady
|
||||||
|
metadataArtifactReady = func(ctx context.Context) error {
|
||||||
|
close(ready)
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { metadataArtifactReady = previous })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() { writeDone <- writeMetadata(ctx, config, time.Now()) }()
|
||||||
|
<-ready
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err := <-writeDone; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("metadata cancellation error=%v, want context canceled", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(resultsDir, "metadata.json")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("metadata final file exists after cancellation: %v", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(resultsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read results directory: %v", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".artifact-") {
|
||||||
|
t.Fatalf("metadata temporary artifact survived cancellation: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_SelectsNATRuntimeScenario(t *testing.T) {
|
||||||
|
// Given
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
config, err := parseFlags([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", t.TempDir(), "--scenario", "nat"}, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse NAT flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When
|
||||||
|
execution, err := selectScenarioExecution(config)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select NAT scenario: %v", err)
|
||||||
|
}
|
||||||
|
if execution.name != "nat" || execution.run == nil {
|
||||||
|
t.Fatalf("unexpected NAT execution: %#v", execution)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
scenarioArtifactPublished = func(string) error { return nil }
|
||||||
|
privateArtifactClose = (*os.File).Close
|
||||||
|
privateArtifactRemove = os.Remove
|
||||||
|
)
|
||||||
|
|
||||||
|
func writePrivateArtifact(path string, data []byte) (err error) {
|
||||||
|
return writePrivateArtifactWithSeam(path, data, func() error {
|
||||||
|
return scenarioArtifactPublished(filepath.Base(path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePrivateArtifactWithSeam(path string, data []byte, beforeRename func() error) (err error) {
|
||||||
|
directory := filepath.Dir(path)
|
||||||
|
temporary, err := os.CreateTemp(directory, ".artifact-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temporary artifact: %w", err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
committed := false
|
||||||
|
closed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
if !closed {
|
||||||
|
err = errors.Join(err, privateArtifactClose(temporary))
|
||||||
|
}
|
||||||
|
err = errors.Join(err, privateArtifactRemove(temporaryPath))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := temporary.Chmod(0o600); err != nil {
|
||||||
|
return fmt.Errorf("secure temporary artifact: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(append(data, '\n')); err != nil {
|
||||||
|
return fmt.Errorf("write temporary artifact: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("sync temporary artifact: %w", err)
|
||||||
|
}
|
||||||
|
closeErr := privateArtifactClose(temporary)
|
||||||
|
closed = true
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("close temporary artifact: %w", closeErr)
|
||||||
|
}
|
||||||
|
if err := beforeRename(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(temporaryPath, path); err != nil {
|
||||||
|
return fmt.Errorf("rename temporary artifact: %w", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/agent"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/dashboard"
|
||||||
|
processharness "github.com/nezhahq/nezha/integration/agentcompat/internal/process"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/scenario"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func completeReconnectDispatchEvidence(t *testing.T) scenario.ReconnectEvidence {
|
||||||
|
t.Helper()
|
||||||
|
disconnectAt := time.Date(2026, 2, 3, 4, 5, 6, 700, time.UTC)
|
||||||
|
reconnectAt := disconnectAt.Add(7 * time.Second)
|
||||||
|
dashboardReceipt := dashboard.MCPReceiptPair{
|
||||||
|
Task: dashboard.MCPReceiptEvent{Sequence: 51, DashboardGeneration: 12, GateGeneration: 61, ServerID: 41, TaskID: 71, TaskType: 81, Kind: dashboard.MCPReceiptTask},
|
||||||
|
Result: dashboard.MCPReceiptEvent{Sequence: 52, DashboardGeneration: 12, GateGeneration: 61, ServerID: 41, TaskID: 71, TaskType: 81, Kind: dashboard.MCPReceiptResult},
|
||||||
|
}
|
||||||
|
agentReceipt := dashboard.MCPReceiptPair{
|
||||||
|
Task: dashboard.MCPReceiptEvent{Sequence: 53, DashboardGeneration: 12, GateGeneration: 62, ServerID: 41, TaskID: 72, TaskType: 82, Kind: dashboard.MCPReceiptTask},
|
||||||
|
Result: dashboard.MCPReceiptEvent{Sequence: 54, DashboardGeneration: 12, GateGeneration: 62, ServerID: 41, TaskID: 72, TaskType: 82, Kind: dashboard.MCPReceiptResult},
|
||||||
|
}
|
||||||
|
evidence := scenario.ReconnectEvidence{
|
||||||
|
Fixture: scenario.ReconnectFixtureEvidence{
|
||||||
|
Dashboard: dashboard.FixtureIdentity{
|
||||||
|
WorkspaceRoot: "/typed/dashboard-workspace", ConfigPath: "/typed/dashboard.yaml", DatabasePath: "/typed/dashboard.sqlite", BinaryPath: "/typed/dashboard",
|
||||||
|
HTTP: workspace.ListenerIdentity{Address: "127.0.0.1:41001", Inode: 1101}, Receipt: workspace.ListenerIdentity{Address: "127.0.0.1:41002", Inode: 1102}, HTTPS: workspace.ListenerIdentity{Address: "127.0.0.1:41003", Inode: 1103},
|
||||||
|
},
|
||||||
|
AgentRoot: "/typed/agent-workspace", AgentConfigPath: "/typed/agent.yaml", AgentBinaryPath: "/typed/agent",
|
||||||
|
},
|
||||||
|
Runtime: scenario.ReconnectRuntimeEvidence{
|
||||||
|
DashboardBefore: dashboard.RuntimeIdentity{Generation: 11, PID: 2101, ProcessGroupID: 3101}, DashboardAfter: dashboard.RuntimeIdentity{Generation: 12, PID: 2102, ProcessGroupID: 3102},
|
||||||
|
AgentBefore: agent.ProcessIdentity{Generation: 21, PID: 2201, ProcessGroupID: 3201}, AgentAfter: agent.ProcessIdentity{Generation: 22, PID: 2202, ProcessGroupID: 3202},
|
||||||
|
StateGenerationBeforeAgentRestart: 31, StateGenerationAfterAgentRestart: 32,
|
||||||
|
},
|
||||||
|
Identity: scenario.ReconnectIdentityEvidence{ServerID: 41, UUID: "00000000-0000-0000-0000-000000000041", DashboardConfigUnchanged: true, AgentConfigUnchanged: true, DashboardFixtureUnchanged: true, ClientsRecreated: true, BootstrapRecreated: true},
|
||||||
|
Lifecycle: scenario.ReconnectLifecycleEvidence{
|
||||||
|
DisconnectAt: disconnectAt, ReconnectAt: reconnectAt, ReconnectInterval: 7 * time.Second,
|
||||||
|
DashboardReceipts: []dashboard.MCPReceiptPair{dashboardReceipt}, AgentReceipts: []dashboard.MCPReceiptPair{agentReceipt},
|
||||||
|
StaleGenerationReceipts: 0, DuplicateTaskIDs: 0, LostResultIDs: 0, OutsideRootSentinelUnchanged: true,
|
||||||
|
},
|
||||||
|
Observation: scenario.ReconnectObservation{ServerID: 41, UUID: "00000000-0000-0000-0000-000000000041", OldGeneration: 11, NewGeneration: 12, DisconnectAt: disconnectAt, ReconnectAt: reconnectAt, TaskIDs: []uint64{71, 72}, ResultIDs: []uint64{71, 72}, PostReconnect: true, AgentRestarted: true},
|
||||||
|
AgentCleanup: processharness.CleanupReceipt{Passed: true, Forced: false, Processes: []processharness.CleanupRecord{{Name: "agent-generation-21", PID: 2201, Forced: false, Error: ""}, {Name: "agent-generation-22", PID: 2202, Forced: false, Error: ""}}},
|
||||||
|
DashboardCleanup: processharness.CleanupReceipt{Passed: true, Forced: false, Processes: []processharness.CleanupRecord{{Name: "dashboard-generation-11", PID: 2101, Forced: false, Error: ""}, {Name: "dashboard-generation-12", PID: 2102, Forced: false, Error: ""}}},
|
||||||
|
}
|
||||||
|
assertCompleteReconnectDispatchEvidence(t, evidence)
|
||||||
|
return evidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCompleteReconnectDispatchEvidence(t *testing.T, evidence scenario.ReconnectEvidence) {
|
||||||
|
t.Helper()
|
||||||
|
if err := evidence.Validate(); err != nil {
|
||||||
|
t.Fatalf("incomplete reconnect dispatch fixture: %v", err)
|
||||||
|
}
|
||||||
|
fixture := evidence.Fixture
|
||||||
|
if fixture.Dashboard.WorkspaceRoot == "" || fixture.Dashboard.ConfigPath == "" || fixture.Dashboard.DatabasePath == "" || fixture.Dashboard.BinaryPath == "" || fixture.AgentRoot == "" || fixture.AgentConfigPath == "" || fixture.AgentBinaryPath == "" {
|
||||||
|
t.Fatal("incomplete reconnect dispatch fixture paths")
|
||||||
|
}
|
||||||
|
for name, listener := range map[string]struct {
|
||||||
|
address string
|
||||||
|
inode uint64
|
||||||
|
}{
|
||||||
|
"http": {fixture.Dashboard.HTTP.Address, fixture.Dashboard.HTTP.Inode},
|
||||||
|
"receipt": {fixture.Dashboard.Receipt.Address, fixture.Dashboard.Receipt.Inode},
|
||||||
|
"https": {fixture.Dashboard.HTTPS.Address, fixture.Dashboard.HTTPS.Inode},
|
||||||
|
} {
|
||||||
|
if listener.address == "" || listener.inode == 0 {
|
||||||
|
t.Fatalf("incomplete reconnect dispatch %s listener", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(evidence.Lifecycle.DashboardReceipts) == 0 || len(evidence.Lifecycle.AgentReceipts) == 0 {
|
||||||
|
t.Fatal("incomplete reconnect dispatch receipt pairs")
|
||||||
|
}
|
||||||
|
for _, pairs := range [][]dashboard.MCPReceiptPair{evidence.Lifecycle.DashboardReceipts, evidence.Lifecycle.AgentReceipts} {
|
||||||
|
for _, pair := range pairs {
|
||||||
|
if pair.Task.Sequence == 0 || pair.Task.DashboardGeneration == 0 || pair.Task.GateGeneration == 0 || pair.Task.ServerID == 0 || pair.Task.TaskID == 0 || pair.Task.TaskType == 0 || pair.Task.Kind != dashboard.MCPReceiptTask {
|
||||||
|
t.Fatalf("incomplete reconnect dispatch task receipt: %#v", pair.Task)
|
||||||
|
}
|
||||||
|
if pair.Result.Sequence == 0 || pair.Result.DashboardGeneration == 0 || pair.Result.GateGeneration == 0 || pair.Result.ServerID == 0 || pair.Result.TaskID == 0 || pair.Result.TaskType == 0 || pair.Result.Kind != dashboard.MCPReceiptResult {
|
||||||
|
t.Fatalf("incomplete reconnect dispatch result receipt: %#v", pair.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertCompleteCleanupReceipt(t, "agent", evidence.AgentCleanup)
|
||||||
|
assertCompleteCleanupReceipt(t, "dashboard", evidence.DashboardCleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCompleteCleanupReceipt(t *testing.T, name string, receipt processharness.CleanupReceipt) {
|
||||||
|
t.Helper()
|
||||||
|
if !receipt.Passed || receipt.Forced || len(receipt.Processes) == 0 {
|
||||||
|
t.Fatalf("incomplete reconnect dispatch %s cleanup receipt: %#v", name, receipt)
|
||||||
|
}
|
||||||
|
for _, record := range receipt.Processes {
|
||||||
|
if record.Name == "" || record.PID == 0 || record.Forced || record.Error != "" {
|
||||||
|
t.Fatalf("incomplete reconnect dispatch %s cleanup record: %#v", name, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/scenario"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_RegisteredScenariosHaveExhaustiveRuntimeRouting(t *testing.T) {
|
||||||
|
for _, definition := range contract.ScenarioDefinitions() {
|
||||||
|
t.Run(definition.Name, func(t *testing.T) {
|
||||||
|
config := testCLIConfig(t, definition.Name, "")
|
||||||
|
execution, err := selectScenarioExecution(config)
|
||||||
|
if definition.Execution == contract.ScenarioExecutionMetadata {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("metadata unexpectedly received runtime execution")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select registered scenario: %v", err)
|
||||||
|
}
|
||||||
|
if execution.name != definition.Name || execution.run == nil {
|
||||||
|
t.Fatalf("incomplete runtime execution: %#v", execution)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_Todos11To15DispatchPropagatesTypedInputsErrorsAndOutputs(t *testing.T) {
|
||||||
|
runnerError := errors.New("injected runner error")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fault string
|
||||||
|
set func(*testing.T, scenario.Result, error, *bool)
|
||||||
|
}{
|
||||||
|
{contract.ScenarioRegistrationConfigExec, contract.FaultAgentBadSecret, func(t *testing.T, want scenario.Result, wantErr error, called *bool) {
|
||||||
|
previous := runRegistrationConfigExecScenario
|
||||||
|
runRegistrationConfigExecScenario = func(_ context.Context, input scenario.RegistrationConfigExecInput) (scenario.Result, error) {
|
||||||
|
*called = true
|
||||||
|
assertPathsAndFault(t, input.Paths, input.Fault, contract.FaultAgentBadSecret)
|
||||||
|
return want, wantErr
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { runRegistrationConfigExecScenario = previous })
|
||||||
|
}},
|
||||||
|
{contract.ScenarioNAT, "", func(t *testing.T, want scenario.Result, wantErr error, called *bool) {
|
||||||
|
previous := runNATScenario
|
||||||
|
runNATScenario = func(_ context.Context, input scenario.NATInput) (scenario.Result, error) {
|
||||||
|
*called = true
|
||||||
|
assertPathsAndFault(t, input.Paths, input.Fault, "")
|
||||||
|
return want, wantErr
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { runNATScenario = previous })
|
||||||
|
}},
|
||||||
|
{contract.ScenarioLegacyFM, contract.FaultAgentBadSecret, func(t *testing.T, want scenario.Result, wantErr error, called *bool) {
|
||||||
|
previous := runLegacyFMScenario
|
||||||
|
runLegacyFMScenario = func(_ context.Context, input scenario.LegacyFMInput) (scenario.Result, error) {
|
||||||
|
*called = true
|
||||||
|
assertPathsAndFault(t, input.Paths, input.Fault, contract.FaultAgentBadSecret)
|
||||||
|
return want, wantErr
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { runLegacyFMScenario = previous })
|
||||||
|
}},
|
||||||
|
{contract.ScenarioTerminal, "", func(t *testing.T, want scenario.Result, wantErr error, called *bool) {
|
||||||
|
previous := runTerminalScenario
|
||||||
|
runTerminalScenario = func(_ context.Context, input scenario.TerminalInput) (scenario.Result, error) {
|
||||||
|
*called = true
|
||||||
|
assertPathsAndFault(t, input.Paths, input.Fault, "")
|
||||||
|
return want, wantErr
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { runTerminalScenario = previous })
|
||||||
|
}},
|
||||||
|
{contract.ScenarioMCPFilesystem, "", func(t *testing.T, want scenario.Result, wantErr error, called *bool) {
|
||||||
|
previous := runMCPFilesystemScenario
|
||||||
|
runMCPFilesystemScenario = func(_ context.Context, input scenario.MCPFilesystemInput) (scenario.Result, error) {
|
||||||
|
*called = true
|
||||||
|
if input.Paths.NezhaSource().String() != "/src/nezha" || input.Paths.AgentSource().String() != "/src/agent" {
|
||||||
|
t.Fatalf("MCP filesystem paths=%#v", input.Paths)
|
||||||
|
}
|
||||||
|
return want, wantErr
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { runMCPFilesystemScenario = previous })
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
want := scenario.Result{Name: test.name, Passed: false, Assertions: []scenario.Assertion{{Name: "runner assertion", Passed: false}}, Error: runnerError.Error()}
|
||||||
|
called := false
|
||||||
|
test.set(t, want, runnerError, &called)
|
||||||
|
execution, err := selectScenarioExecution(testCLIConfig(t, test.name, test.fault))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select execution: %v", err)
|
||||||
|
}
|
||||||
|
output, err := execution.run(t.Context())
|
||||||
|
if !errors.Is(err, runnerError) {
|
||||||
|
t.Fatalf("runner error=%v", err)
|
||||||
|
}
|
||||||
|
if !called || output.Result.Name != want.Name || output.Result.Error != want.Error || output.Transfer != nil || output.Reconnect != nil {
|
||||||
|
t.Fatalf("runner propagation called=%t output=%#v", called, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPathsAndFault(t *testing.T, paths contract.Paths, fault contract.Fault, wantFault string) {
|
||||||
|
t.Helper()
|
||||||
|
if paths.NezhaSource().String() != "/src/nezha" || paths.AgentSource().String() != "/src/agent" || fault.String() != wantFault {
|
||||||
|
t.Fatalf("paths/fault propagation paths=%#v fault=%q", paths, fault.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/scenario"
|
||||||
|
)
|
||||||
|
|
||||||
|
type transferArtifact struct {
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
Fault string `json:"fault,omitempty"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
CleanupOK bool `json:"cleanup_ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Evidence scenario.TransferEvidence `json:"evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reconnectArtifact struct {
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
Fault string `json:"fault,omitempty"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
CleanupOK bool `json:"cleanup_ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Evidence scenario.ReconnectEvidence `json:"evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeScenarioEvidence(config cliConfig, output scenarioExecutionOutput, now time.Time) error {
|
||||||
|
if err := output.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate scenario execution output: %w", err)
|
||||||
|
}
|
||||||
|
result := output.Result
|
||||||
|
if !result.Passed && config.Fault.String() != "" && allAssertionsPassed(result.Assertions) {
|
||||||
|
result.Assertions = append(result.Assertions, scenario.Assertion{Name: contract.AssertionInjectedFault, Passed: false, Details: result.Error})
|
||||||
|
}
|
||||||
|
if output.Transfer != nil {
|
||||||
|
artifact := transferArtifact{Scenario: result.Name, Fault: config.Fault.String(), Passed: result.Passed, CleanupOK: result.CleanupOK, Error: evidence.Redact(result.Error), Evidence: *output.Transfer}
|
||||||
|
if err := writeJSONArtifact(config.Paths.ResultsDir().String(), "transfer.json", artifact); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output.Reconnect != nil {
|
||||||
|
artifact := reconnectArtifact{Scenario: result.Name, Fault: config.Fault.String(), Passed: result.Passed, CleanupOK: result.CleanupOK, Error: evidence.Redact(result.Error), Evidence: *output.Reconnect}
|
||||||
|
if err := writeJSONArtifact(config.Paths.ResultsDir().String(), "reconnect.json", artifact); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertions := make([]evidence.Assertion, 0, len(result.Assertions))
|
||||||
|
for _, assertion := range result.Assertions {
|
||||||
|
assertions = append(assertions, evidence.Assertion{Name: assertion.Name, Passed: assertion.Passed, Details: assertion.Details})
|
||||||
|
}
|
||||||
|
results := evidence.Results{Profile: string(config.Profile.Name()), Passed: result.Passed, Scenarios: []evidence.ScenarioResult{{Name: result.Name, Passed: result.Passed, Assertions: assertions, Error: result.Error}}}
|
||||||
|
data, err := evidence.MarshalResults(results)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal scenario results: %w", err)
|
||||||
|
}
|
||||||
|
if err := writePrivateFile(filepath.Join(config.Paths.ResultsDir().String(), "results.json"), data); err != nil {
|
||||||
|
return fmt.Errorf("write scenario results: %w", err)
|
||||||
|
}
|
||||||
|
junit, err := evidence.JUnit(results)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal scenario junit: %w", err)
|
||||||
|
}
|
||||||
|
if err := writePrivateFile(filepath.Join(config.Paths.ResultsDir().String(), "junit.xml"), junit); err != nil {
|
||||||
|
return fmt.Errorf("write scenario junit: %w", err)
|
||||||
|
}
|
||||||
|
cleanup := struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
FinishedAt string `json:"finished_at"`
|
||||||
|
}{Passed: result.CleanupOK, Scenario: result.Name, FinishedAt: now.UTC().Format(time.RFC3339)}
|
||||||
|
return writeJSONArtifact(config.Paths.ResultsDir().String(), "cleanup.json", cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONArtifact(resultsDir, name string, artifact any) error {
|
||||||
|
data, err := json.MarshalIndent(artifact, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if redacted := evidence.Redact(string(data)); redacted != string(data) {
|
||||||
|
return fmt.Errorf("credential detected while marshaling %s", name)
|
||||||
|
}
|
||||||
|
if err := writePrivateFile(filepath.Join(resultsDir, name), data); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePrivateFile(path string, data []byte) error {
|
||||||
|
return writePrivateArtifact(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allAssertionsPassed(assertions []scenario.Assertion) bool {
|
||||||
|
for _, assertion := range assertions {
|
||||||
|
if !assertion.Passed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/scenario"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scenarioExecutionOutput struct {
|
||||||
|
Result scenario.Result
|
||||||
|
Transfer *scenario.TransferEvidence
|
||||||
|
Reconnect *scenario.ReconnectEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func (output scenarioExecutionOutput) Validate() error {
|
||||||
|
if output.Result.Name == "" {
|
||||||
|
return errors.New("scenario execution result is missing")
|
||||||
|
}
|
||||||
|
dedicatedCount := 0
|
||||||
|
if output.Transfer != nil {
|
||||||
|
dedicatedCount++
|
||||||
|
}
|
||||||
|
if output.Reconnect != nil {
|
||||||
|
dedicatedCount++
|
||||||
|
}
|
||||||
|
switch output.Result.Name {
|
||||||
|
case contract.ScenarioTransfer100MiB:
|
||||||
|
if dedicatedCount != 1 || output.Transfer == nil {
|
||||||
|
return errors.New("transfer execution requires only transfer evidence")
|
||||||
|
}
|
||||||
|
case contract.ScenarioReconnect:
|
||||||
|
if dedicatedCount != 1 || output.Reconnect == nil {
|
||||||
|
return errors.New("reconnect execution requires only reconnect evidence")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if dedicatedCount != 0 {
|
||||||
|
return errors.New("scenario execution has mismatched dedicated evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type scenarioExecution struct {
|
||||||
|
name string
|
||||||
|
run func(context.Context) (scenarioExecutionOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
runRegistrationConfigExecScenario = (scenario.RegistrationConfigExec{}).Run
|
||||||
|
runNATScenario = (scenario.NAT{}).Run
|
||||||
|
runLegacyFMScenario = (scenario.LegacyFM{}).Run
|
||||||
|
runTerminalScenario = (scenario.Terminal{}).Run
|
||||||
|
runMCPFilesystemScenario = (scenario.MCPFilesystem{}).Run
|
||||||
|
runTransferScenario = (scenario.Transfer{}).RunWithEvidence
|
||||||
|
runReconnectScenario = (scenario.Reconnect{}).RunWithEvidence
|
||||||
|
)
|
||||||
|
|
||||||
|
func selectScenarioExecution(config cliConfig) (scenarioExecution, error) {
|
||||||
|
if len(config.Scenarios) != 1 {
|
||||||
|
return scenarioExecution{}, errors.New("runtime execution requires exactly one --scenario")
|
||||||
|
}
|
||||||
|
selected := config.Scenarios[0]
|
||||||
|
if err := contract.ValidateScenarioFault(selected, config.Fault); err != nil {
|
||||||
|
return scenarioExecution{}, err
|
||||||
|
}
|
||||||
|
definition, err := contract.ScenarioDefinitionByName(selected.String())
|
||||||
|
if err != nil {
|
||||||
|
return scenarioExecution{}, err
|
||||||
|
}
|
||||||
|
switch definition.Execution {
|
||||||
|
case contract.ScenarioExecutionMetadata:
|
||||||
|
return scenarioExecution{}, errors.New("metadata scenario does not have a runtime execution")
|
||||||
|
case contract.ScenarioExecutionRegistrationConfigExec:
|
||||||
|
return standardExecution(selected.String(), func(ctx context.Context) (scenario.Result, error) {
|
||||||
|
return runRegistrationConfigExecScenario(ctx, scenario.RegistrationConfigExecInput{Paths: config.Paths, Fault: config.Fault})
|
||||||
|
}), nil
|
||||||
|
case contract.ScenarioExecutionNAT:
|
||||||
|
return standardExecution(selected.String(), func(ctx context.Context) (scenario.Result, error) {
|
||||||
|
return runNATScenario(ctx, scenario.NATInput{Paths: config.Paths, Fault: config.Fault})
|
||||||
|
}), nil
|
||||||
|
case contract.ScenarioExecutionLegacyFM:
|
||||||
|
return standardExecution(selected.String(), func(ctx context.Context) (scenario.Result, error) {
|
||||||
|
return runLegacyFMScenario(ctx, scenario.LegacyFMInput{Paths: config.Paths, Fault: config.Fault})
|
||||||
|
}), nil
|
||||||
|
case contract.ScenarioExecutionTerminal:
|
||||||
|
return standardExecution(selected.String(), func(ctx context.Context) (scenario.Result, error) {
|
||||||
|
return runTerminalScenario(ctx, scenario.TerminalInput{Paths: config.Paths, Fault: config.Fault})
|
||||||
|
}), nil
|
||||||
|
case contract.ScenarioExecutionMCPFilesystem:
|
||||||
|
return standardExecution(selected.String(), func(ctx context.Context) (scenario.Result, error) {
|
||||||
|
return runMCPFilesystemScenario(ctx, scenario.MCPFilesystemInput{Paths: config.Paths})
|
||||||
|
}), nil
|
||||||
|
case contract.ScenarioExecutionTransfer:
|
||||||
|
return scenarioExecution{name: selected.String(), run: func(ctx context.Context) (scenarioExecutionOutput, error) {
|
||||||
|
result, transferEvidence, err := runTransferScenario(ctx, scenario.TransferInput{Paths: config.Paths, Fault: config.Fault})
|
||||||
|
return scenarioExecutionOutput{Result: result, Transfer: &transferEvidence}, err
|
||||||
|
}}, nil
|
||||||
|
case contract.ScenarioExecutionReconnect:
|
||||||
|
return scenarioExecution{name: selected.String(), run: func(ctx context.Context) (scenarioExecutionOutput, error) {
|
||||||
|
result, reconnectEvidence, err := runReconnectScenario(ctx, scenario.ReconnectInput{Paths: config.Paths, DashboardFault: config.Fault.String()})
|
||||||
|
return scenarioExecutionOutput{Result: result, Reconnect: &reconnectEvidence}, err
|
||||||
|
}}, nil
|
||||||
|
default:
|
||||||
|
return scenarioExecution{}, errors.New("runtime execution is not implemented for the selected scenario")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func standardExecution(name string, run func(context.Context) (scenario.Result, error)) scenarioExecution {
|
||||||
|
return scenarioExecution{name: name, run: func(ctx context.Context) (scenarioExecutionOutput, error) {
|
||||||
|
result, err := run(ctx)
|
||||||
|
return scenarioExecutionOutput{Result: result}, err
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_FailedGenerationRemovesPriorEvidence(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
seedStaleEvidence(t, resultsDir)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if err := run([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", resultsDir, "--scenario", "future-scenario"}, &stdout, &stderr, time.Now()); err == nil {
|
||||||
|
t.Fatal("unimplemented runtime unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
for _, name := range append(evidence.FixedEvidenceFiles()[1:], "agents") {
|
||||||
|
if _, err := os.Stat(filepath.Join(resultsDir, name)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale artifact remains: %s (%v)", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_FailedGenerationRemovesPriorEvidenceWhenMetadataIsSymlink(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
target := filepath.Join(t.TempDir(), "metadata-target")
|
||||||
|
if err := os.WriteFile(target, []byte("old metadata"), 0o600); err != nil {
|
||||||
|
t.Fatalf("seed metadata target: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(resultsDir, "metadata.json")); err != nil {
|
||||||
|
t.Fatalf("create metadata symlink: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"results.json", "junit.xml"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(resultsDir, name), []byte("stale success"), 0o600); err != nil {
|
||||||
|
t.Fatalf("seed stale artifact %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if err := run([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--profile", "pr-full", "--results-dir", resultsDir, "--scenario", "future-scenario"}, &stdout, &stderr, time.Now()); err == nil {
|
||||||
|
t.Fatal("unimplemented runtime unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
metadataInfo, err := os.Lstat(filepath.Join(resultsDir, "metadata.json"))
|
||||||
|
if err != nil || !metadataInfo.Mode().IsRegular() || metadataInfo.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("replacement metadata is not a private regular file: mode=%v err=%v", metadataInfo, err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"results.json", "junit.xml"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(resultsDir, name)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale artifact remains after failed invocation: %s (%v)", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(target)
|
||||||
|
if err != nil || string(data) != "old metadata" {
|
||||||
|
t.Fatalf("metadata symlink target changed: %q %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_ParseFailureRemovesPriorEvidence(t *testing.T) {
|
||||||
|
for name, extraArgs := range map[string][]string{
|
||||||
|
"invalid profile": {"--profile", "invalid-profile"},
|
||||||
|
"invalid seed": {"--profile", "pr-full", "--seed", "invalid-seed"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
resultsDir := t.TempDir()
|
||||||
|
seedStaleEvidence(t, resultsDir)
|
||||||
|
args := append([]string{"--nezha-source", "/src/nezha", "--agent-source", "/src/agent", "--results-dir", resultsDir}, extraArgs...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if err := run(args, &stdout, &stderr, time.Now()); err == nil {
|
||||||
|
t.Fatal("invalid CLI input unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
for _, artifact := range evidence.FixedEvidenceFiles()[1:] {
|
||||||
|
if _, err := os.Stat(filepath.Join(resultsDir, artifact)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale artifact remains after parse failure: %s (%v)", artifact, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedStaleEvidence(t *testing.T, resultsDir string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range evidence.FixedEvidenceFiles()[1:] {
|
||||||
|
if err := os.WriteFile(filepath.Join(resultsDir, name), []byte("stale success"), 0o600); err != nil {
|
||||||
|
t.Fatalf("seed stale artifact %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(resultsDir, "agents"), 0o700); err != nil {
|
||||||
|
t.Fatalf("create stale agents directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(resultsDir, "agents", "old.log"), []byte("old success"), 0o600); err != nil {
|
||||||
|
t.Fatalf("seed stale agent log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user