mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
test(agentcompat): define integration contracts
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResourceWarmupRuns = 1
|
||||||
|
ResourceSampleCount = 5
|
||||||
|
ResourceSampleInterval = 250 * time.Millisecond
|
||||||
|
ResourceExpectedCountDrift = 0
|
||||||
|
DashboardRSSDeltaBytes uint64 = 64 * 1024 * 1024
|
||||||
|
AgentRSSDeltaBytes uint64 = 32 * 1024 * 1024
|
||||||
|
TransferHeapBytes uint64 = 16 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResourceBudgetInput struct {
|
||||||
|
WarmupRuns int
|
||||||
|
SampleCount int
|
||||||
|
SampleInterval time.Duration
|
||||||
|
ChildProcessCountDrift int
|
||||||
|
ListenerCountDrift int
|
||||||
|
NonStdioFDCountDrift int
|
||||||
|
DashboardRSSDeltaBytes uint64
|
||||||
|
AgentRSSDeltaBytes uint64
|
||||||
|
TransferHeapBytes uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceBudget struct{ input ResourceBudgetInput }
|
||||||
|
|
||||||
|
func NewResourceBudget(input ResourceBudgetInput) (ResourceBudget, error) {
|
||||||
|
if input.WarmupRuns < 1 || input.SampleCount < 1 || input.SampleInterval <= 0 || input.ChildProcessCountDrift < 0 || input.ListenerCountDrift < 0 || input.NonStdioFDCountDrift < 0 || input.DashboardRSSDeltaBytes == 0 || input.AgentRSSDeltaBytes == 0 || input.TransferHeapBytes == 0 {
|
||||||
|
return ResourceBudget{}, errors.New("invalid resource budget")
|
||||||
|
}
|
||||||
|
return ResourceBudget{input: input}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b ResourceBudget) WarmupRuns() int { return b.input.WarmupRuns }
|
||||||
|
func (b ResourceBudget) SampleCount() int { return b.input.SampleCount }
|
||||||
|
func (b ResourceBudget) SampleInterval() time.Duration { return b.input.SampleInterval }
|
||||||
|
func (b ResourceBudget) ChildProcessCountDrift() int { return b.input.ChildProcessCountDrift }
|
||||||
|
func (b ResourceBudget) ListenerCountDrift() int { return b.input.ListenerCountDrift }
|
||||||
|
func (b ResourceBudget) NonStdioFDCountDrift() int { return b.input.NonStdioFDCountDrift }
|
||||||
|
func (b ResourceBudget) DashboardRSSDeltaBytes() uint64 { return b.input.DashboardRSSDeltaBytes }
|
||||||
|
func (b ResourceBudget) AgentRSSDeltaBytes() uint64 { return b.input.AgentRSSDeltaBytes }
|
||||||
|
func (b ResourceBudget) TransferHeapBytes() uint64 { return b.input.TransferHeapBytes }
|
||||||
|
|
||||||
|
func DefaultResourceBudget() ResourceBudget {
|
||||||
|
return ResourceBudget{input: ResourceBudgetInput{WarmupRuns: ResourceWarmupRuns, SampleCount: ResourceSampleCount, SampleInterval: ResourceSampleInterval, ChildProcessCountDrift: ResourceExpectedCountDrift, ListenerCountDrift: ResourceExpectedCountDrift, NonStdioFDCountDrift: ResourceExpectedCountDrift, DashboardRSSDeltaBytes: DashboardRSSDeltaBytes, AgentRSSDeltaBytes: AgentRSSDeltaBytes, TransferHeapBytes: TransferHeapBytes}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContract_Profiles(t *testing.T) {
|
||||||
|
pr, err := ProfileByName("pr-full")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse pr-full: %v", err)
|
||||||
|
}
|
||||||
|
if pr.JobTimeout() != 75*time.Minute || pr.SuiteDeadline() != 55*time.Minute {
|
||||||
|
t.Fatalf("unexpected pr-full deadlines: %#v", pr)
|
||||||
|
}
|
||||||
|
if pr.Seed() != Seed(0x4e5a4841) || pr.AgentCount() != 8 || pr.StressRounds() != 4 || pr.ConcurrentOperations() != 64 {
|
||||||
|
t.Fatalf("unexpected pr-full load: %#v", pr)
|
||||||
|
}
|
||||||
|
if pr.ConcurrentSessions() != 4 || pr.TransferPairs() != 1 || pr.DashboardRestartCycles() != 1 {
|
||||||
|
t.Fatalf("unexpected pr-full sessions: %#v", pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
soak, err := ProfileByName("soak")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse soak: %v", err)
|
||||||
|
}
|
||||||
|
if soak.SuiteDeadline() != 150*time.Minute || soak.AgentCount() != 20 || soak.Iterations() != 3 || soak.DashboardRestartCycles() != 10 || soak.TransferPairs() != 5 || !soak.StreamBoundaryCheck() {
|
||||||
|
t.Fatalf("unexpected soak profile: %#v", soak)
|
||||||
|
}
|
||||||
|
if soak.JobTimeout() != 150*time.Minute || soak.Seed() != DefaultSeed || soak.StreamBoundaryAllowed() != 40 || soak.StreamBoundaryRejected() != 41 || soak.TransferBytes() != 100*1024*1024 {
|
||||||
|
t.Fatalf("unexpected soak limits: %#v", soak)
|
||||||
|
}
|
||||||
|
if _, err := ProfileByName("unknown"); err == nil {
|
||||||
|
t.Fatal("unknown profile accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_ResourceBudget(t *testing.T) {
|
||||||
|
budget, err := NewResourceBudget(ResourceBudgetInput{
|
||||||
|
WarmupRuns: 1,
|
||||||
|
SampleCount: 5,
|
||||||
|
SampleInterval: 250 * time.Millisecond,
|
||||||
|
ChildProcessCountDrift: 0,
|
||||||
|
ListenerCountDrift: 0,
|
||||||
|
NonStdioFDCountDrift: 0,
|
||||||
|
DashboardRSSDeltaBytes: 64 * 1024 * 1024,
|
||||||
|
AgentRSSDeltaBytes: 32 * 1024 * 1024,
|
||||||
|
TransferHeapBytes: 16 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct budget: %v", err)
|
||||||
|
}
|
||||||
|
if budget.WarmupRuns() != 1 || budget.SampleCount() != 5 || budget.SampleInterval() != 250*time.Millisecond || budget.ChildProcessCountDrift() != 0 || budget.ListenerCountDrift() != 0 || budget.NonStdioFDCountDrift() != 0 {
|
||||||
|
t.Fatalf("unexpected sampling budget: %#v", budget)
|
||||||
|
}
|
||||||
|
if budget.DashboardRSSDeltaBytes() != 64*1024*1024 || budget.AgentRSSDeltaBytes() != 32*1024*1024 || budget.TransferHeapBytes() != 16*1024*1024 {
|
||||||
|
t.Fatalf("unexpected memory budget: %#v", budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_ResourceBudgetRejectsInvalidInput(t *testing.T) {
|
||||||
|
base := ResourceBudgetInput{WarmupRuns: 1, SampleCount: 5, SampleInterval: 250 * time.Millisecond, DashboardRSSDeltaBytes: 1, AgentRSSDeltaBytes: 1, TransferHeapBytes: 1}
|
||||||
|
for name, mutate := range map[string]func(*ResourceBudgetInput){
|
||||||
|
"zero warmup": func(input *ResourceBudgetInput) { input.WarmupRuns = 0 },
|
||||||
|
"zero samples": func(input *ResourceBudgetInput) { input.SampleCount = 0 },
|
||||||
|
"negative samples": func(input *ResourceBudgetInput) { input.SampleCount = -1 },
|
||||||
|
"zero interval": func(input *ResourceBudgetInput) { input.SampleInterval = 0 },
|
||||||
|
"negative interval": func(input *ResourceBudgetInput) { input.SampleInterval = -time.Millisecond },
|
||||||
|
"negative child drift": func(input *ResourceBudgetInput) { input.ChildProcessCountDrift = -1 },
|
||||||
|
"missing dashboard threshold": func(input *ResourceBudgetInput) { input.DashboardRSSDeltaBytes = 0 },
|
||||||
|
"missing agent threshold": func(input *ResourceBudgetInput) { input.AgentRSSDeltaBytes = 0 },
|
||||||
|
"missing heap threshold": func(input *ResourceBudgetInput) { input.TransferHeapBytes = 0 },
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
input := base
|
||||||
|
mutate(&input)
|
||||||
|
if _, err := NewResourceBudget(input); err == nil {
|
||||||
|
t.Fatal("invalid budget accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_CLIValues(t *testing.T) {
|
||||||
|
paths, err := NewPaths("/src/nezha", "/src/agent", "/tmp/results")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct paths: %v", err)
|
||||||
|
}
|
||||||
|
if paths.NezhaSource().String() != "/src/nezha" || paths.AgentSource().String() != "/src/agent" || paths.ResultsDir().String() != "/tmp/results" {
|
||||||
|
t.Fatalf("unexpected paths: %#v", paths)
|
||||||
|
}
|
||||||
|
scenario, err := NewScenario("metadata")
|
||||||
|
if err != nil || scenario.String() != "metadata" {
|
||||||
|
t.Fatalf("construct scenario: %q %v", scenario.String(), err)
|
||||||
|
}
|
||||||
|
fault, err := NewFault("transfer-hash")
|
||||||
|
if err != nil || fault.String() != "transfer-hash" {
|
||||||
|
t.Fatalf("construct fault: %q %v", fault.String(), err)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"", "../escape", "has space"} {
|
||||||
|
if _, err := NewScenario(invalid); err == nil {
|
||||||
|
t.Fatalf("invalid scenario accepted: %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProfileName string
|
||||||
|
type Seed uint64
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProfilePRFull ProfileName = "pr-full"
|
||||||
|
ProfileSoak ProfileName = "soak"
|
||||||
|
DefaultSeed Seed = 0x4e5a4841
|
||||||
|
|
||||||
|
PRFullJobTimeout = 75 * time.Minute
|
||||||
|
PRFullSuiteDeadline = 55 * time.Minute
|
||||||
|
PRFullAgentCount = 8
|
||||||
|
PRFullStressRounds = 4
|
||||||
|
PRFullConcurrentOperations = 64
|
||||||
|
PRFullConcurrentSessions = 4
|
||||||
|
PRFullTransferPairs = 1
|
||||||
|
PRFullRestartCycles = 1
|
||||||
|
|
||||||
|
SoakJobTimeout = 150 * time.Minute
|
||||||
|
SoakSuiteDeadline = 150 * time.Minute
|
||||||
|
SoakAgentCount = 20
|
||||||
|
SoakStressRounds = 4
|
||||||
|
SoakConcurrentOperations = 160
|
||||||
|
SoakConcurrentSessions = 4
|
||||||
|
SoakTransferPairs = 5
|
||||||
|
SoakRestartCycles = 10
|
||||||
|
SoakIterations = 3
|
||||||
|
|
||||||
|
TransferBytes uint64 = 100 * 1024 * 1024
|
||||||
|
StreamBoundaryAllowed = 40
|
||||||
|
StreamBoundaryRejected = 41
|
||||||
|
)
|
||||||
|
|
||||||
|
type Profile struct {
|
||||||
|
name ProfileName
|
||||||
|
jobTimeout time.Duration
|
||||||
|
suiteDeadline time.Duration
|
||||||
|
seed Seed
|
||||||
|
agentCount int
|
||||||
|
stressRounds int
|
||||||
|
concurrentOperations int
|
||||||
|
concurrentSessions int
|
||||||
|
transferPairs int
|
||||||
|
dashboardRestartCycles int
|
||||||
|
iterations int
|
||||||
|
streamBoundaryAllowed int
|
||||||
|
streamBoundaryRejected int
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProfileByName(name string) (Profile, error) {
|
||||||
|
switch ProfileName(name) {
|
||||||
|
case ProfilePRFull:
|
||||||
|
return Profile{name: ProfilePRFull, jobTimeout: PRFullJobTimeout, suiteDeadline: PRFullSuiteDeadline, seed: DefaultSeed, agentCount: PRFullAgentCount, stressRounds: PRFullStressRounds, concurrentOperations: PRFullConcurrentOperations, concurrentSessions: PRFullConcurrentSessions, transferPairs: PRFullTransferPairs, dashboardRestartCycles: PRFullRestartCycles, iterations: 1, streamBoundaryAllowed: StreamBoundaryAllowed, streamBoundaryRejected: StreamBoundaryRejected}, nil
|
||||||
|
case ProfileSoak:
|
||||||
|
return Profile{name: ProfileSoak, jobTimeout: SoakJobTimeout, suiteDeadline: SoakSuiteDeadline, seed: DefaultSeed, agentCount: SoakAgentCount, stressRounds: SoakStressRounds, concurrentOperations: SoakConcurrentOperations, concurrentSessions: SoakConcurrentSessions, transferPairs: SoakTransferPairs, dashboardRestartCycles: SoakRestartCycles, iterations: SoakIterations, streamBoundaryAllowed: StreamBoundaryAllowed, streamBoundaryRejected: StreamBoundaryRejected}, nil
|
||||||
|
default:
|
||||||
|
return Profile{}, errors.New("unknown profile; expected pr-full or soak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Profile) Name() ProfileName { return p.name }
|
||||||
|
func (p Profile) JobTimeout() time.Duration { return p.jobTimeout }
|
||||||
|
func (p Profile) SuiteDeadline() time.Duration { return p.suiteDeadline }
|
||||||
|
func (p Profile) Seed() Seed { return p.seed }
|
||||||
|
func (p Profile) AgentCount() int { return p.agentCount }
|
||||||
|
func (p Profile) StressRounds() int { return p.stressRounds }
|
||||||
|
func (p Profile) ConcurrentOperations() int { return p.concurrentOperations }
|
||||||
|
func (p Profile) ConcurrentSessions() int { return p.concurrentSessions }
|
||||||
|
func (p Profile) TransferPairs() int { return p.transferPairs }
|
||||||
|
func (p Profile) DashboardRestartCycles() int { return p.dashboardRestartCycles }
|
||||||
|
func (p Profile) Iterations() int { return p.iterations }
|
||||||
|
func (p Profile) TransferBytes() uint64 { return TransferBytes }
|
||||||
|
func (p Profile) StreamBoundaryAllowed() int { return p.streamBoundaryAllowed }
|
||||||
|
func (p Profile) StreamBoundaryRejected() int { return p.streamBoundaryRejected }
|
||||||
|
func (p Profile) StreamBoundaryCheck() bool {
|
||||||
|
return p.streamBoundaryAllowed > 0 && p.streamBoundaryRejected > p.streamBoundaryAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrInvalidSeed = errors.New("invalid seed")
|
||||||
|
|
||||||
|
func ParseSeed(raw string) (Seed, error) {
|
||||||
|
value, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X"), 16, 64)
|
||||||
|
if err != nil || value == 0 {
|
||||||
|
return 0, fmt.Errorf("%w; expected nonzero hexadecimal", ErrInvalidSeed)
|
||||||
|
}
|
||||||
|
return Seed(value), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScenarioMetadata = "metadata"
|
||||||
|
ScenarioRegistrationConfigExec = "registration-config-exec"
|
||||||
|
ScenarioNAT = "nat"
|
||||||
|
ScenarioLegacyFM = "legacy-fm"
|
||||||
|
ScenarioTerminal = "terminal"
|
||||||
|
ScenarioMCPFilesystem = "mcp-filesystem"
|
||||||
|
ScenarioTransfer100MiB = "transfer-100mib"
|
||||||
|
ScenarioReconnect = "reconnect"
|
||||||
|
|
||||||
|
FaultAgentBadSecret = "agent-bad-secret"
|
||||||
|
FaultTransferHash = "transfer-hash"
|
||||||
|
FaultDashboardExit = "dashboard-exit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DedicatedArtifactKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
DedicatedArtifactNone DedicatedArtifactKind = iota
|
||||||
|
DedicatedArtifactTransfer
|
||||||
|
DedicatedArtifactReconnect
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScenarioExecutionKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScenarioExecutionMetadata ScenarioExecutionKind = iota
|
||||||
|
ScenarioExecutionRegistrationConfigExec
|
||||||
|
ScenarioExecutionNAT
|
||||||
|
ScenarioExecutionLegacyFM
|
||||||
|
ScenarioExecutionTerminal
|
||||||
|
ScenarioExecutionMCPFilesystem
|
||||||
|
ScenarioExecutionTransfer
|
||||||
|
ScenarioExecutionReconnect
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScenarioDefinition struct {
|
||||||
|
Name string
|
||||||
|
AllowedFaults []string
|
||||||
|
Execution ScenarioExecutionKind
|
||||||
|
DedicatedArtifact DedicatedArtifactKind
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssertionDefinition struct {
|
||||||
|
Name string
|
||||||
|
Passed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
AssertionInjectedFault = "injected fault produced the expected scenario failure"
|
||||||
|
|
||||||
|
AssertionTransferWarmup = "small real upload and download warm-up precedes event and deadline quiescence"
|
||||||
|
AssertionTransferUpload = "exact 100MiB upload has size mode SHA and create_dirs"
|
||||||
|
AssertionTransferDownload = "exact 100MiB download has equal nonempty SHA"
|
||||||
|
AssertionTransferUploadReplay = "upload token replay is typed unauthorized"
|
||||||
|
AssertionTransferDownloadReplay = "download token replay is typed unauthorized"
|
||||||
|
AssertionTransferOversize = "100MiB plus one upload is typed too large"
|
||||||
|
AssertionTransferResidue = "Dashboard spool and Agent temp residue are zero"
|
||||||
|
AssertionTransferSentinels = "outside-root sentinels remain unchanged"
|
||||||
|
AssertionTransferHeap = "retained live heap stays within 16MiB"
|
||||||
|
AssertionTransferCleanup = "process listener and workspace cleanup completed"
|
||||||
|
AssertionTransferHashRejected = "transfer-hash rejects upload with typed 502"
|
||||||
|
AssertionTransferHashTargetAbsent = "transfer-hash leaves target absent"
|
||||||
|
|
||||||
|
AssertionReconnectDisconnect = "Dashboard disconnect barrier stopped generation one"
|
||||||
|
AssertionReconnectDashboard = "Dashboard generation two preserves fixture and recreates runtime clients"
|
||||||
|
AssertionReconnectIdentity = "Agent reconnect preserves exact server ID and UUID"
|
||||||
|
AssertionReconnectReceipts = "post-reconnect MCP task and result receipts are exactly once"
|
||||||
|
AssertionReconnectStale = "stale Dashboard generation cannot receive new task receipts"
|
||||||
|
AssertionReconnectAgent = "Agent restart advances state stream and preserves config identity"
|
||||||
|
AssertionReconnectSentinel = "outside-root sentinel remains unchanged"
|
||||||
|
AssertionReconnectCleanup = "multi-generation process listener and workspace cleanup completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (definition ScenarioDefinition) DedicatedArtifactName() string {
|
||||||
|
switch definition.DedicatedArtifact {
|
||||||
|
case DedicatedArtifactNone:
|
||||||
|
return ""
|
||||||
|
case DedicatedArtifactTransfer:
|
||||||
|
return "transfer.json"
|
||||||
|
case DedicatedArtifactReconnect:
|
||||||
|
return "reconnect.json"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (definition ScenarioDefinition) Assertions(fault string) []AssertionDefinition {
|
||||||
|
switch definition.Name {
|
||||||
|
case ScenarioTransfer100MiB:
|
||||||
|
if fault == FaultTransferHash {
|
||||||
|
return assertions(
|
||||||
|
AssertionTransferWarmup, AssertionTransferHashRejected, AssertionTransferHashTargetAbsent,
|
||||||
|
AssertionTransferResidue, AssertionTransferSentinels, AssertionTransferCleanup, AssertionInjectedFault,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return assertions(
|
||||||
|
AssertionTransferWarmup, AssertionTransferUpload, AssertionTransferDownload,
|
||||||
|
AssertionTransferUploadReplay, AssertionTransferDownloadReplay, AssertionTransferOversize,
|
||||||
|
AssertionTransferResidue, AssertionTransferSentinels, AssertionTransferHeap, AssertionTransferCleanup,
|
||||||
|
)
|
||||||
|
case ScenarioReconnect:
|
||||||
|
if fault == FaultDashboardExit {
|
||||||
|
return assertions(AssertionReconnectDisconnect, AssertionReconnectSentinel, AssertionReconnectCleanup, AssertionInjectedFault)
|
||||||
|
}
|
||||||
|
return assertions(
|
||||||
|
AssertionReconnectDisconnect, AssertionReconnectDashboard, AssertionReconnectIdentity,
|
||||||
|
AssertionReconnectReceipts, AssertionReconnectStale, AssertionReconnectAgent,
|
||||||
|
AssertionReconnectSentinel, AssertionReconnectCleanup,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertions(names ...string) []AssertionDefinition {
|
||||||
|
definitions := make([]AssertionDefinition, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
definitions = append(definitions, AssertionDefinition{Name: name, Passed: name != AssertionInjectedFault})
|
||||||
|
}
|
||||||
|
return definitions
|
||||||
|
}
|
||||||
|
|
||||||
|
var scenarioDefinitions = []ScenarioDefinition{
|
||||||
|
{Name: ScenarioMetadata, AllowedFaults: []string{""}, Execution: ScenarioExecutionMetadata},
|
||||||
|
{Name: ScenarioRegistrationConfigExec, AllowedFaults: []string{"", FaultAgentBadSecret}, Execution: ScenarioExecutionRegistrationConfigExec},
|
||||||
|
{Name: ScenarioNAT, AllowedFaults: []string{""}, Execution: ScenarioExecutionNAT},
|
||||||
|
{Name: ScenarioLegacyFM, AllowedFaults: []string{"", FaultAgentBadSecret}, Execution: ScenarioExecutionLegacyFM},
|
||||||
|
{Name: ScenarioTerminal, AllowedFaults: []string{""}, Execution: ScenarioExecutionTerminal},
|
||||||
|
{Name: ScenarioMCPFilesystem, AllowedFaults: []string{""}, Execution: ScenarioExecutionMCPFilesystem},
|
||||||
|
{Name: ScenarioTransfer100MiB, AllowedFaults: []string{"", FaultTransferHash}, Execution: ScenarioExecutionTransfer, DedicatedArtifact: DedicatedArtifactTransfer},
|
||||||
|
{Name: ScenarioReconnect, AllowedFaults: []string{"", FaultDashboardExit}, Execution: ScenarioExecutionReconnect, DedicatedArtifact: DedicatedArtifactReconnect},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScenarioDefinitions() []ScenarioDefinition {
|
||||||
|
definitions := make([]ScenarioDefinition, len(scenarioDefinitions))
|
||||||
|
copy(definitions, scenarioDefinitions)
|
||||||
|
for index := range definitions {
|
||||||
|
definitions[index].AllowedFaults = slices.Clone(definitions[index].AllowedFaults)
|
||||||
|
}
|
||||||
|
return definitions
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScenarioDefinitionByName(name string) (ScenarioDefinition, error) {
|
||||||
|
for _, definition := range scenarioDefinitions {
|
||||||
|
if definition.Name == name {
|
||||||
|
definition.AllowedFaults = slices.Clone(definition.AllowedFaults)
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ScenarioDefinition{}, errors.New("unsupported scenario")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateScenarioFault(scenario Scenario, fault Fault) error {
|
||||||
|
definition, err := ScenarioDefinitionByName(scenario.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !slices.Contains(definition.AllowedFaults, fault.String()) {
|
||||||
|
return errors.New("unsupported fault for scenario")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsSupportedScenario(name string) bool {
|
||||||
|
_, err := ScenarioDefinitionByName(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContract_ScenarioFaultRegistry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
scenario string
|
||||||
|
fault string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{ScenarioTransfer100MiB, "", true},
|
||||||
|
{ScenarioTransfer100MiB, FaultTransferHash, true},
|
||||||
|
{ScenarioTransfer100MiB, FaultDashboardExit, false},
|
||||||
|
{ScenarioReconnect, "", true},
|
||||||
|
{ScenarioReconnect, FaultDashboardExit, true},
|
||||||
|
{ScenarioReconnect, FaultTransferHash, false},
|
||||||
|
{ScenarioRegistrationConfigExec, FaultAgentBadSecret, true},
|
||||||
|
{ScenarioLegacyFM, FaultAgentBadSecret, true},
|
||||||
|
{ScenarioMCPFilesystem, FaultAgentBadSecret, false},
|
||||||
|
{"future-scenario", "", false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
scenario, err := NewScenario(test.scenario)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct scenario %q: %v", test.scenario, err)
|
||||||
|
}
|
||||||
|
fault := Fault{}
|
||||||
|
if test.fault != "" {
|
||||||
|
fault, err = NewFault(test.fault)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct fault %q: %v", test.fault, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = ValidateScenarioFault(scenario, fault)
|
||||||
|
if (err == nil) != test.valid {
|
||||||
|
t.Fatalf("scenario=%q fault=%q valid=%t err=%v", test.scenario, test.fault, test.valid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_SyntacticConstructorsPreserveArbitraryValidNames(t *testing.T) {
|
||||||
|
if _, err := NewScenario("future-scenario"); err != nil {
|
||||||
|
t.Fatalf("valid future scenario syntax rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NewFault("future-fault"); err != nil {
|
||||||
|
t.Fatalf("valid future fault syntax rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContract_ScenarioDefinitionsAreDeterministicAndTyped(t *testing.T) {
|
||||||
|
definitions := ScenarioDefinitions()
|
||||||
|
wantNames := []string{
|
||||||
|
ScenarioMetadata,
|
||||||
|
ScenarioRegistrationConfigExec,
|
||||||
|
ScenarioNAT,
|
||||||
|
ScenarioLegacyFM,
|
||||||
|
ScenarioTerminal,
|
||||||
|
ScenarioMCPFilesystem,
|
||||||
|
ScenarioTransfer100MiB,
|
||||||
|
ScenarioReconnect,
|
||||||
|
}
|
||||||
|
gotNames := make([]string, 0, len(definitions))
|
||||||
|
seenExecution := make(map[ScenarioExecutionKind]struct{}, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
gotNames = append(gotNames, definition.Name)
|
||||||
|
if len(definition.AllowedFaults) == 0 || definition.AllowedFaults[0] != "" {
|
||||||
|
t.Fatalf("scenario %q does not explicitly allow the no-fault path", definition.Name)
|
||||||
|
}
|
||||||
|
if _, exists := seenExecution[definition.Execution]; exists {
|
||||||
|
t.Fatalf("execution kind is duplicated: %d", definition.Execution)
|
||||||
|
}
|
||||||
|
seenExecution[definition.Execution] = struct{}{}
|
||||||
|
if definition.DedicatedArtifactName() == "" && definition.DedicatedArtifact != DedicatedArtifactNone {
|
||||||
|
t.Fatalf("scenario %q has unnamed dedicated artifact", definition.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(gotNames, wantNames) {
|
||||||
|
t.Fatalf("scenario enumeration order=%v want=%v", gotNames, wantNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package contract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||||
|
|
||||||
|
type NezhaSourcePath struct{ value string }
|
||||||
|
type AgentSourcePath struct{ value string }
|
||||||
|
type ResultsPath struct{ value string }
|
||||||
|
|
||||||
|
type Paths struct {
|
||||||
|
nezhaSource NezhaSourcePath
|
||||||
|
agentSource AgentSourcePath
|
||||||
|
resultsDir ResultsPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPaths(nezhaSource, agentSource, resultsDir string) (Paths, error) {
|
||||||
|
nezha, err := cleanAbsolutePath(nezhaSource)
|
||||||
|
if err != nil {
|
||||||
|
return Paths{}, errors.New("invalid --nezha-source path")
|
||||||
|
}
|
||||||
|
agent, err := cleanAbsolutePath(agentSource)
|
||||||
|
if err != nil {
|
||||||
|
return Paths{}, errors.New("invalid --agent-source path")
|
||||||
|
}
|
||||||
|
results, err := cleanAbsolutePath(resultsDir)
|
||||||
|
if err != nil {
|
||||||
|
return Paths{}, errors.New("invalid --results-dir path")
|
||||||
|
}
|
||||||
|
return Paths{nezhaSource: NezhaSourcePath{value: nezha}, agentSource: AgentSourcePath{value: agent}, resultsDir: ResultsPath{value: results}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanAbsolutePath(raw string) (string, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" || !filepath.IsAbs(raw) {
|
||||||
|
return "", errors.New("path must be absolute")
|
||||||
|
}
|
||||||
|
return filepath.Clean(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Paths) NezhaSource() NezhaSourcePath { return p.nezhaSource }
|
||||||
|
func (p Paths) AgentSource() AgentSourcePath { return p.agentSource }
|
||||||
|
func (p Paths) ResultsDir() ResultsPath { return p.resultsDir }
|
||||||
|
func (p NezhaSourcePath) String() string { return p.value }
|
||||||
|
func (p AgentSourcePath) String() string { return p.value }
|
||||||
|
func (p ResultsPath) String() string { return p.value }
|
||||||
|
|
||||||
|
type Scenario struct{ value string }
|
||||||
|
|
||||||
|
func NewScenario(raw string) (Scenario, error) {
|
||||||
|
if !namePattern.MatchString(raw) {
|
||||||
|
return Scenario{}, errors.New("invalid scenario name")
|
||||||
|
}
|
||||||
|
return Scenario{value: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Scenario) String() string { return s.value }
|
||||||
|
|
||||||
|
type Fault struct{ value string }
|
||||||
|
|
||||||
|
func NewFault(raw string) (Fault, error) {
|
||||||
|
if !namePattern.MatchString(raw) {
|
||||||
|
return Fault{}, errors.New("invalid fault name")
|
||||||
|
}
|
||||||
|
return Fault{value: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Fault) String() string { return f.value }
|
||||||
|
func (f Fault) IsZero() bool { return f.value == "" }
|
||||||
Reference in New Issue
Block a user