test(agentcompat): add agent runtime harness

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-07-20 04:45:05 +00:00
co-authored by naiba/CloudCode
parent 3a9e0c7887
commit 2a3245c412
15 changed files with 1980 additions and 0 deletions
@@ -0,0 +1,59 @@
//go:build linux && agentcompat
package agent
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAgent_RejectsMalformedStartWithoutWorkspaceArtifact(t *testing.T) {
// Given
parent := t.TempDir()
t.Setenv("TMPDIR", parent)
// When
_, err := Start(t.Context(), AgentStartConfig{SourceDir: filepath.Join(parent, "missing"), Endpoint: "127.0.0.1:1", UUID: "bad"})
// Then
require.Error(t, err)
entries, readErr := os.ReadDir(parent)
require.NoError(t, readErr)
require.Empty(t, entries)
}
func TestAgent_ContextInterruptionCleansWorkspace(t *testing.T) {
// Given
processContext, interrupt := context.WithCancel(t.Context())
dashboardInstance := startTestDashboard(t, false)
agentInstance, err := Start(processContext, AgentStartConfig{
SourceDir: testAgentSourceDir(t),
Endpoint: dashboardInstance.Endpoint(),
Secret: dashboardInstance.AgentSecret(),
UUID: "00000000-0000-0000-0000-000000000085",
})
require.NoError(t, err)
t.Cleanup(func() {
cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, agentInstance.Stop(cleanupContext))
})
root := agentInstance.WorkspaceRoot()
// When
interrupt()
// Then
select {
case <-agentInstance.CleanupDone():
case <-time.After(30 * time.Second):
t.Fatal("agent cleanup did not complete")
}
_, err = os.Stat(root)
require.ErrorIs(t, err, os.ErrNotExist)
}
@@ -0,0 +1,239 @@
//go:build linux
package agent
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/nezhahq/nezha/integration/agentcompat/internal/dashboard"
processharness "github.com/nezhahq/nezha/integration/agentcompat/internal/process"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workspace"
)
const (
agentSecret = "0123456789abcdef0123456789abcdef"
agentMaxLogBytes = 1 << 20
agentStopTimeout = 5 * time.Second
agentKillTimeout = 5 * time.Second
)
type AgentStartConfig struct {
SourceDir string
PreparedBinary *PreparedBinary
Endpoint string
Secret string
UUID string
TLS bool
Debug bool
CAFilePath string
FMObserverRunID string
Credential *syscall.Credential
newSupervisor func(context.Context, processharness.Spec) *processharness.Supervisor
trackPID func(int) error
trackProcessGroup func(int) error
}
type AgentStartError struct {
cause error
agent *Agent
}
func (err *AgentStartError) Error() string { return err.cause.Error() }
func (err *AgentStartError) Unwrap() error { return err.cause }
func (err *AgentStartError) Finalize(ctx context.Context) error {
return err.agent.Stop(ctx)
}
type Agent struct {
workspace *workspace.Workspace
supervisor *processharness.Supervisor
clients dashboard.Clients
configPath string
logPath string
binaryPath string
caFilePath string
environment []string
secret string
uuid string
releaseBinary func()
releasePending bool
cleanupOnce sync.Once
cleanupDone chan struct{}
cleanupAttemptMu sync.Mutex
cleanupMu sync.Mutex
cleanupErr error
readinessMu sync.Mutex
lastStateReport time.Time
fmObserver *FMProducerObserver
fmObserverPath string
startConfig AgentStartConfig
processMu sync.Mutex
currentProcess *processGeneration
processes []*processGeneration
generation uint64
closed bool
trackPID func(int) error
trackProcessGroup func(int) error
}
func Start(ctx context.Context, config AgentStartConfig) (*Agent, error) {
if config.PreparedBinary == nil {
if err := validateSourceDir(config.SourceDir); err != nil {
return nil, err
}
}
if config.Endpoint == "" || config.UUID == "" {
return nil, errors.New("agent endpoint and UUID are required")
}
if config.Secret == "" {
config.Secret = agentSecret
}
workspaceRoot, err := workspace.New(context.WithoutCancel(ctx))
if err != nil {
return nil, fmt.Errorf("create agent workspace: %w", err)
}
trackPID := workspaceRoot.TrackPID
if config.trackPID != nil {
trackPID = config.trackPID
}
trackProcessGroup := workspaceRoot.TrackProcessGroup
if config.trackProcessGroup != nil {
trackProcessGroup = config.trackProcessGroup
}
agent := &Agent{workspace: workspaceRoot, secret: config.Secret, uuid: config.UUID, cleanupDone: make(chan struct{}), startConfig: config, trackPID: trackPID, trackProcessGroup: trackProcessGroup}
if err := agent.prepareFixture(ctx, config); err != nil {
return nil, cleanupFailedStart(ctx, agent, err)
}
if _, err := agent.StartProcess(ctx); err != nil {
return nil, cleanupFailedStart(ctx, agent, err)
}
go agent.cleanupOnCancellation(ctx)
return agent, nil
}
func validateSourceDir(sourceDir string) error {
if sourceDir == "" || !filepath.IsAbs(sourceDir) {
return errors.New("agent source directory must be absolute")
}
return nil
}
func cleanupFailedStart(ctx context.Context, agent *Agent, cause error) error {
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
startError := errors.Join(cause, agent.Stop(cleanupContext))
if agent.finalizationPending() {
// A failed rollback can leave the prepared binary leased until its process group exits.
return &AgentStartError{cause: startError, agent: agent}
}
return startError
}
func filteredEnvironment() []string {
result := make([]string, 0, len(os.Environ()))
for _, value := range os.Environ() {
if strings.HasPrefix(value, "NZ_") || strings.HasPrefix(value, "SSL_CERT_FILE=") || strings.HasPrefix(value, "AGENTCOMPAT_FM_OBSERVER_") {
continue
}
result = append(result, value)
}
return result
}
func (agent *Agent) Stop(ctx context.Context) error {
agent.cleanupOnce.Do(func() { go agent.cleanup(context.WithoutCancel(ctx)) })
select {
case <-agent.cleanupDone:
agent.retryFinalization(ctx)
return agent.cleanupResult()
case <-ctx.Done():
return ctx.Err()
}
}
func (agent *Agent) cleanupOnCancellation(ctx context.Context) {
select {
case <-ctx.Done():
agent.cleanupOnce.Do(func() { go agent.cleanup(context.WithoutCancel(ctx)) })
case <-agent.cleanupDone:
}
}
func (agent *Agent) cleanup(ctx context.Context) {
defer close(agent.cleanupDone)
agent.finishCleanup(ctx, true)
}
func (agent *Agent) finishCleanup(ctx context.Context, closeObserver bool) {
agent.cleanupAttemptMu.Lock()
defer agent.cleanupAttemptMu.Unlock()
cleanupError := agent.closeProcesses(ctx)
if closeObserver && agent.fmObserver != nil {
cleanupError = errors.Join(cleanupError, agent.fmObserver.Close(), removeFMObserverSocket(agent.fmObserverPath))
}
if err := agent.workspace.Close(); err != nil {
cleanupError = errors.Join(cleanupError, err)
}
// The prepared workspace must outlive every consumer process group, even when process cleanup reports an error.
if agent.releasePending && agent.processesQuiescent() {
agent.releaseBinary()
agent.releasePending = false
}
agent.cleanupMu.Lock()
if agent.cleanupErr == nil {
agent.cleanupErr = cleanupError
}
agent.cleanupMu.Unlock()
}
func (agent *Agent) retryFinalization(ctx context.Context) {
if agent.finalizationPending() {
agent.finishCleanup(context.WithoutCancel(ctx), false)
}
}
func (agent *Agent) finalizationPending() bool {
agent.cleanupAttemptMu.Lock()
defer agent.cleanupAttemptMu.Unlock()
return agent.releasePending
}
func (agent *Agent) cleanupResult() error {
agent.cleanupMu.Lock()
defer agent.cleanupMu.Unlock()
return agent.cleanupErr
}
func closeError(first, second error) error { return errors.Join(first, second) }
func (agent *Agent) UUID() string { return agent.uuid }
func (agent *Agent) PID() int {
agent.processMu.Lock()
defer agent.processMu.Unlock()
if agent.currentProcess == nil {
return 0
}
return agent.currentProcess.identity.PID
}
func (agent *Agent) CleanupReceipt() processharness.CleanupReceipt {
agent.processMu.Lock()
defer agent.processMu.Unlock()
records := make([]processharness.CleanupRecord, 0, len(agent.processes))
for _, process := range agent.processes {
records = append(records, process.record)
}
return processharness.NewCleanupReceipt(records)
}
func (agent *Agent) ConfigPath() string { return agent.configPath }
func (agent *Agent) BinaryPath() string { return agent.binaryPath }
func (agent *Agent) LogPath() string { return agent.logPath }
func (agent *Agent) WorkspaceRoot() string { return agent.workspace.Root() }
func (agent *Agent) CleanupDone() <-chan struct{} { return agent.cleanupDone }
func (agent *Agent) FMProducerObserver() *FMProducerObserver { return agent.fmObserver }
@@ -0,0 +1,294 @@
//go:build linux && agentcompat
package agent
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/integration/agentcompat/internal/client"
"github.com/nezhahq/nezha/integration/agentcompat/internal/dashboard"
"github.com/nezhahq/nezha/integration/agentcompat/internal/fixture"
"github.com/nezhahq/nezha/integration/agentcompat/internal/testpaths"
)
func TestAgent_BecomesOnlineOverH2C(t *testing.T) {
// Given
dashboardInstance := startTestDashboardWithReceiptGate(t)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{UUID: "00000000-0000-0000-0000-000000000081"})
receiptAccepted := make(chan error, 1)
go func() { receiptAccepted <- dashboardInstance.WaitForReceiptAccepted(t.Context()) }()
select {
case err := <-receiptAccepted:
require.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for withheld state receipt")
}
serverBeforeRelease := requireOnlineServer(t, dashboardInstance, agentInstance.UUID())
require.NotZero(t, serverBeforeRelease.LastActive)
stateGeneration := dashboardInstance.StateGeneration(serverBeforeRelease.ID, agentInstance.UUID())
require.NotZero(t, stateGeneration)
require.NoError(t, dashboardInstance.WaitForStateGeneration(t.Context(), serverBeforeRelease.ID, agentInstance.UUID(), stateGeneration, 1))
stateTwoBeforeRelease, cancelStateTwo := context.WithTimeout(t.Context(), 1500*time.Millisecond)
require.ErrorIs(t, dashboardInstance.WaitForStateGeneration(stateTwoBeforeRelease, serverBeforeRelease.ID, agentInstance.UUID(), stateGeneration, 2), context.DeadlineExceeded)
cancelStateTwo()
require.Equal(t, uint64(1), dashboardInstance.ReceiptAcceptedCount())
require.NoError(t, dashboardInstance.ReleaseReceipt(t.Context()))
secondState := make(chan error, 1)
go func() { secondState <- dashboardInstance.WaitForSecondState(t.Context()) }()
// When
readiness, err := agentInstance.WaitReady(t.Context(), dashboardInstance)
// Then
require.NoError(t, err)
require.NotZero(t, readiness.ServerID)
require.Equal(t, serverBeforeRelease.ID, readiness.ServerID)
require.Equal(t, agentInstance.UUID(), readiness.UUID)
require.Equal(t, "v2.1.0", readiness.Version)
require.True(t, readiness.VersionObserved)
require.True(t, readiness.RequestTaskEstablished)
require.True(t, readiness.StateReceiptObserved)
require.NoError(t, <-secondState)
require.Equal(t, uint64(2), dashboardInstance.ReceiptAcceptedCount())
require.NoError(t, dashboardInstance.WaitForInfo2(t.Context(), serverBeforeRelease.ID, agentInstance.UUID()))
require.NotNil(t, readiness.Host)
require.NotNil(t, readiness.State)
require.True(t, readiness.Online)
}
func TestAgent_BecomesOnlineOverVerifiedTLS(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, true)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{
UUID: "00000000-0000-0000-0000-000000000082",
TLS: true,
CAFilePath: dashboardInstance.TLSCACertificatePath(),
})
// When
readiness, err := agentInstance.WaitReady(t.Context(), dashboardInstance)
// Then
require.NoError(t, err)
require.True(t, readiness.Online)
require.NotNil(t, readiness.Host)
require.NotNil(t, readiness.State)
require.WithinDuration(t, time.Now(), readiness.LastActive, 30*time.Second)
var host struct {
Platform string `json:"platform"`
Version string `json:"version"`
}
require.NoError(t, json.Unmarshal(readiness.Host, &host))
require.Equal(t, "v2.1.0", host.Version)
require.NotEmpty(t, host.Platform)
var state map[string]json.RawMessage
require.NoError(t, json.Unmarshal(readiness.State, &state))
require.Contains(t, state, "uptime")
}
func TestAgent_RejectsUnknownTLSAuthority(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, true)
wrongCAPath := filepath.Join(t.TempDir(), "wrong-ca.crt")
wrongFixture, fixtureErr := fixture.NewLocalTLSFixture(time.Now())
require.NoError(t, fixtureErr)
require.NoError(t, os.WriteFile(wrongCAPath, wrongFixture.CAPEM(), 0o600))
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{
UUID: "00000000-0000-0000-0000-000000000086",
TLS: true,
Debug: true,
CAFilePath: wrongCAPath,
})
// When
readinessContext, cancel := context.WithTimeout(t.Context(), 8*time.Second)
defer cancel()
_, err := agentInstance.WaitReady(readinessContext, dashboardInstance)
// Then
require.Error(t, err)
require.ErrorIs(t, err, context.DeadlineExceeded)
logData, logErr := os.ReadFile(agentInstance.LogPath())
require.NoError(t, logErr)
require.Contains(t, string(logData), "x509: certificate signed by unknown authority")
config, readErr := os.ReadFile(agentInstance.ConfigPath())
require.NoError(t, readErr)
stopContext, stopCancel := context.WithTimeout(context.Background(), 15*time.Second)
require.NoError(t, agentInstance.Stop(stopContext))
stopCancel()
require.Contains(t, string(config), "insecure_tls: false")
require.NotContains(t, string(config), "insecure_tls: true")
}
func TestAgent_AssertNeverOnlineFailsClosedWhenDashboardUnavailable(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, false)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{
UUID: "00000000-0000-0000-0000-000000000087",
Secret: "wrong-agent-secret",
})
require.NoError(t, dashboardInstance.Stop(context.Background()))
// When
err := agentInstance.AssertNeverOnline(t.Context(), dashboardInstance, time.Second)
// Then
require.Error(t, err)
}
func TestAgent_RejectsInvalidSecret(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, false)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{
UUID: "00000000-0000-0000-0000-000000000083",
Secret: "wrong-agent-secret",
})
// When
err := agentInstance.AssertNeverOnline(t.Context(), dashboardInstance, 2*time.Second)
// Then
require.NoError(t, err)
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, agentInstance.Stop(stopContext))
}
func TestAgent_StopsCleanly(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, false)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{UUID: "00000000-0000-0000-0000-000000000084"})
require.NoError(t, waitForAgentReady(t, agentInstance, dashboardInstance))
// When
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := agentInstance.Stop(stopContext)
// Then
require.NoError(t, err)
require.True(t, agentInstance.CleanupReceipt().Passed)
require.False(t, agentInstance.CleanupReceipt().Forced)
}
func TestAgent_StopRemovesAgentFromOnlineOnlyList(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, false)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{UUID: "00000000-0000-0000-0000-000000000088"})
require.NoError(t, waitForAgentReady(t, agentInstance, dashboardInstance))
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, agentInstance.Stop(stopContext))
// When
list, err := client.CallTool[serverListArguments, serverListResult](
t.Context(), dashboardInstance.Clients().MCP,
client.ToolCall[serverListArguments]{Name: "server.list", Arguments: serverListArguments{OnlineOnly: true}},
)
// Then
require.NoError(t, err)
foundOnline := false
for _, server := range list.StructuredContent.Servers {
if server.UUID == agentInstance.UUID() {
foundOnline = true
}
}
require.False(t, foundOnline)
}
func startTestDashboard(t *testing.T, enableTLS bool) *dashboard.Dashboard {
t.Helper()
sourceDir, err := testpaths.NezhaSource(t.Name())
require.NoError(t, err)
instance, err := dashboard.Start(t.Context(), dashboard.StartConfig{SourceDir: sourceDir, EnableTLS: enableTLS, ReadinessTimeout: readinessBudget})
require.NoError(t, err)
t.Cleanup(func() {
cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, instance.Stop(cleanupContext))
})
return instance
}
func startTestDashboardWithReceiptGate(t *testing.T) *dashboard.Dashboard {
t.Helper()
sourceDir, err := testpaths.NezhaSource(t.Name())
require.NoError(t, err)
instance, err := dashboard.Start(t.Context(), dashboard.StartConfig{SourceDir: sourceDir, ReceiptGate: true, ReadinessTimeout: readinessBudget})
require.NoError(t, err)
t.Cleanup(func() {
cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, instance.Stop(cleanupContext))
})
return instance
}
func startTestAgent(t *testing.T, dashboardInstance *dashboard.Dashboard, config AgentStartConfig) *Agent {
t.Helper()
if config.Secret == "" {
config.Secret = dashboardInstance.AgentSecret()
}
if config.TLS {
config.Endpoint = dashboardInstance.TLSEndpoint()
} else {
config.Endpoint = dashboardInstance.Endpoint()
}
instance, err := Start(t.Context(), AgentStartConfig{
SourceDir: testAgentSourceDir(t),
Endpoint: config.Endpoint,
Secret: config.Secret,
UUID: config.UUID,
TLS: config.TLS,
Debug: config.Debug,
CAFilePath: config.CAFilePath,
})
require.NoError(t, err)
t.Cleanup(func() {
cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, instance.Stop(cleanupContext))
})
return instance
}
func testAgentSourceDir(t *testing.T) string {
t.Helper()
if sourceDir := os.Getenv("AGENT_SOURCE"); sourceDir != "" {
return sourceDir
}
nezhaSource, err := testpaths.NezhaSource(t.Name())
require.NoError(t, err)
agentSourceDir, err := testpaths.AgentSource(nezhaSource)
require.NoError(t, err)
return agentSourceDir
}
func requireOnlineServer(t *testing.T, dashboardInstance *dashboard.Dashboard, uuid string) serverListItem {
t.Helper()
list, err := client.CallTool[serverListArguments, serverListResult](t.Context(), dashboardInstance.Clients().MCP, client.ToolCall[serverListArguments]{Name: "server.list", Arguments: serverListArguments{OnlineOnly: true}})
require.NoError(t, err)
for _, server := range list.StructuredContent.Servers {
if server.UUID == uuid {
return server
}
}
t.Fatalf("server %q is not online", uuid)
return serverListItem{}
}
func waitForAgentReady(t *testing.T, instance *Agent, dashboardInstance *dashboard.Dashboard) error {
t.Helper()
readinessContext, cancel := context.WithTimeout(t.Context(), readinessBudget)
defer cancel()
_, err := instance.WaitReady(readinessContext, dashboardInstance)
return err
}
@@ -0,0 +1,68 @@
//go:build linux && agentcompat
package agent
import (
"context"
"errors"
"syscall"
"testing"
"time"
processharness "github.com/nezhahq/nezha/integration/agentcompat/internal/process"
"github.com/stretchr/testify/require"
)
func TestAgent_StartExposesFinalizerWhenPreparedConsumerSurvivesRollback(t *testing.T) {
prepared, err := PrepareBinary(t.Context(), testAgentSourceDir(t))
require.NoError(t, err)
trackingErr := errors.New("injected failed-start PID tracking error")
var supervisor *processharness.Supervisor
instance, startErr := Start(t.Context(), AgentStartConfig{
PreparedBinary: prepared,
Endpoint: "127.0.0.1:1",
UUID: "00000000-0000-0000-0000-000000000200",
newSupervisor: func(ctx context.Context, spec processharness.Spec) *processharness.Supervisor {
supervisor = processharness.NewSupervisor(ctx, spec)
cancelledContext, cancel := context.WithCancel(ctx)
cancel()
_ = supervisor.Stop(cancelledContext)
require.NoError(t, supervisor.Stop(t.Context()))
return supervisor
},
trackPID: func(int) error { return trackingErr },
})
require.Nil(t, instance)
require.ErrorIs(t, startErr, trackingErr)
require.NotNil(t, supervisor)
pid := supervisor.PID()
processGroupID := supervisor.ProcessGroupID()
t.Cleanup(func() {
_ = syscall.Kill(-processGroupID, syscall.SIGKILL)
select {
case <-supervisor.Exited():
case <-time.After(5 * time.Second):
}
_ = prepared.Close()
})
require.NoError(t, syscall.Kill(-processGroupID, 0))
var startFailure *AgentStartError
require.ErrorAs(t, startErr, &startFailure)
closeErr := prepared.Close()
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, closeErr, &usageErr)
require.Equal(t, "has active consumers", usageErr.Reason)
require.NoError(t, syscall.Kill(-processGroupID, syscall.SIGKILL))
select {
case <-supervisor.Exited():
case <-time.After(5 * time.Second):
t.Fatalf("failed-start consumer PID %d was not reaped", pid)
}
require.ErrorIs(t, syscall.Kill(-processGroupID, 0), syscall.ESRCH)
require.NoError(t, startFailure.Finalize(t.Context()))
require.NoError(t, startFailure.Finalize(t.Context()))
require.NoError(t, prepared.Close())
}
@@ -0,0 +1,119 @@
//go:build linux
package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workspace"
)
func agentBuildSpec(sourceDir string) workspace.BuildSpec {
return workspace.BuildSpec{Name: "agent", SourceDir: sourceDir, Package: "./cmd/agent", Tags: []string{"agentcompat"}, Ldflags: []string{"-X", "github.com/nezhahq/agent/pkg/monitor.Version=v2.1.0"}}
}
func (agent *Agent) prepareFixture(ctx context.Context, config AgentStartConfig) error {
if err := agent.prepareConfig(config); err != nil {
return err
}
if err := agent.prepareFMObserver(config); err != nil {
return err
}
if err := agent.prepareBinary(ctx, config); err != nil {
return err
}
if err := agent.grantWorkspaceOwnership(config); err != nil {
return err
}
agent.prepareEnvironment(config)
return nil
}
func (agent *Agent) prepareConfig(config AgentStartConfig) error {
configPath, err := agent.workspace.PayloadPath("config.yml")
if err != nil {
return err
}
agent.configPath = configPath
content := fmt.Sprintf("server: %q\nclient_secret: %q\nuuid: %q\ndisable_auto_update: true\ndisable_command_execute: false\ndisable_nat: false\nreport_delay: 1\nip_report_period: 30\ntls: %t\ninsecure_tls: false\ndebug: %t\n", config.Endpoint, config.Secret, config.UUID, config.TLS, config.Debug)
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
return fmt.Errorf("write agent config: %w", err)
}
if !config.TLS || config.CAFilePath == "" {
return nil
}
ca, err := os.ReadFile(config.CAFilePath)
if err != nil {
return fmt.Errorf("read agent CA certificate: %w", err)
}
caPath, err := agent.workspace.PayloadPath("agent-ca.crt")
if err != nil {
return err
}
if err := os.WriteFile(caPath, ca, 0o600); err != nil {
return fmt.Errorf("write agent CA certificate: %w", err)
}
agent.caFilePath = caPath
return nil
}
func (agent *Agent) prepareFMObserver(config AgentStartConfig) error {
if config.FMObserverRunID == "" {
return nil
}
agent.fmObserverPath = fmObserverSocketPath(agent.workspace.Root())
observer, err := newFMProducerObserver(agent.fmObserverPath)
if err != nil {
return err
}
agent.fmObserver = observer
return nil
}
func (agent *Agent) prepareBinary(ctx context.Context, config AgentStartConfig) error {
if config.PreparedBinary != nil {
binaryPath, release, err := config.PreparedBinary.acquire()
if err != nil {
return err
}
agent.binaryPath = binaryPath
agent.releaseBinary = release
agent.releasePending = true
return nil
}
binaryPath, err := agent.workspace.Build(ctx, agentBuildSpec(config.SourceDir))
if err != nil {
return err
}
agent.binaryPath = binaryPath
return nil
}
func (agent *Agent) grantWorkspaceOwnership(config AgentStartConfig) error {
if config.Credential == nil {
return nil
}
if err := filepath.WalkDir(agent.workspace.Root(), func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
return os.Chown(path, int(config.Credential.Uid), int(config.Credential.Gid))
}); err != nil {
return fmt.Errorf("grant agent workspace ownership: %w", err)
}
return nil
}
func (agent *Agent) prepareEnvironment(config AgentStartConfig) {
environment := filteredEnvironment()
if config.FMObserverRunID != "" {
environment = append(environment, "AGENTCOMPAT_FM_OBSERVER_SOCKET="+agent.fmObserverPath, "AGENTCOMPAT_FM_OBSERVER_RUN_ID="+config.FMObserverRunID)
}
if config.TLS && agent.caFilePath != "" {
environment = append(environment, "SSL_CERT_FILE="+agent.caFilePath)
}
agent.environment = append([]string(nil), environment...)
}
@@ -0,0 +1,92 @@
//go:build linux
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sync"
)
type FMProducerSample struct {
RunID string `json:"run_id"`
AgentUUID string `json:"agent_uuid"`
SessionID string `json:"session_id"`
Phase string `json:"phase"`
Active int64 `json:"active"`
}
type FMProducerObserver struct {
listener net.Listener
samples chan FMProducerSample
done chan struct{}
once sync.Once
}
func newFMProducerObserver(socketPath string) (*FMProducerObserver, error) {
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, fmt.Errorf("listen for FM producer observations: %w", err)
}
observer := &FMProducerObserver{listener: listener, samples: make(chan FMProducerSample, 16), done: make(chan struct{})}
go observer.accept()
return observer, nil
}
func (observer *FMProducerObserver) accept() {
defer close(observer.done)
for {
connection, err := observer.listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
continue
}
var sample FMProducerSample
err = json.NewDecoder(connection).Decode(&sample)
_ = connection.Close()
if err == nil {
observer.samples <- sample
}
}
}
func (observer *FMProducerObserver) Await(ctx context.Context, match func(FMProducerSample) bool) (FMProducerSample, error) {
for {
select {
case sample := <-observer.samples:
if match(sample) {
return sample, nil
}
case <-ctx.Done():
return FMProducerSample{}, ctx.Err()
}
}
}
func (observer *FMProducerObserver) Close() error {
var closeErr error
observer.once.Do(func() {
closeErr = observer.listener.Close()
<-observer.done
})
return closeErr
}
func fmObserverSocketPath(workspaceRoot string) string {
return filepath.Join(workspaceRoot, "fm-observer.sock")
}
func removeFMObserverSocket(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
@@ -0,0 +1,125 @@
//go:build linux
package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workspace"
)
type PreparedBinaryUsageError struct {
Operation string
Reason string
}
func (err *PreparedBinaryUsageError) Error() string {
return fmt.Sprintf("prepared agent binary %s: %s", err.Operation, err.Reason)
}
// PreparedBinary owns a build-only workspace. Each successful Start lease keeps
// that workspace alive; Agent workspaces own their own config, logs, and process tracking.
type PreparedBinary struct {
workspace *workspace.Workspace
binaryPath string
mu sync.Mutex
consumers int
closed bool
}
func PrepareBinary(ctx context.Context, sourceDir string) (*PreparedBinary, error) {
if err := validateSourceDir(sourceDir); err != nil {
return nil, &PreparedBinaryUsageError{Operation: "prepare", Reason: err.Error()}
}
workspaceRoot, err := workspace.New(context.WithoutCancel(ctx))
if err != nil {
return nil, fmt.Errorf("create prepared agent workspace: %w", err)
}
binaryPath, err := workspaceRoot.Build(ctx, agentBuildSpec(sourceDir))
if err != nil {
return nil, closePreparedWorkspace(workspaceRoot, err)
}
if err := exposePreparedBinary(workspaceRoot.Root(), binaryPath); err != nil {
return nil, closePreparedWorkspace(workspaceRoot, err)
}
return &PreparedBinary{workspace: workspaceRoot, binaryPath: binaryPath}, nil
}
func closePreparedWorkspace(workspaceRoot *workspace.Workspace, cause error) error {
return fmt.Errorf("prepare agent binary: %w", closeError(cause, workspaceRoot.Close()))
}
func exposePreparedBinary(root, binaryPath string) error {
for _, path := range []string{root, filepath.Dir(binaryPath)} {
if err := os.Chmod(path, 0o755); err != nil {
return fmt.Errorf("make prepared agent binary executable: %w", err)
}
}
return nil
}
func (prepared *PreparedBinary) BinaryPath() string {
if prepared == nil {
return ""
}
prepared.mu.Lock()
defer prepared.mu.Unlock()
return prepared.binaryPath
}
func (prepared *PreparedBinary) WorkspaceRoot() string {
if prepared == nil {
return ""
}
prepared.mu.Lock()
defer prepared.mu.Unlock()
return prepared.workspace.Root()
}
func (prepared *PreparedBinary) acquire() (string, func(), error) {
if prepared == nil {
return "", nil, &PreparedBinaryUsageError{Operation: "start", Reason: "is nil"}
}
prepared.mu.Lock()
defer prepared.mu.Unlock()
if prepared.workspace == nil || prepared.binaryPath == "" {
return "", nil, &PreparedBinaryUsageError{Operation: "start", Reason: "is uninitialized"}
}
if prepared.closed {
return "", nil, &PreparedBinaryUsageError{Operation: "start", Reason: "is closed"}
}
prepared.consumers++
return prepared.binaryPath, prepared.release, nil
}
func (prepared *PreparedBinary) release() {
prepared.mu.Lock()
defer prepared.mu.Unlock()
prepared.consumers--
}
func (prepared *PreparedBinary) Close() error {
if prepared == nil {
return &PreparedBinaryUsageError{Operation: "close", Reason: "is nil"}
}
prepared.mu.Lock()
defer prepared.mu.Unlock()
if prepared.workspace == nil || prepared.binaryPath == "" {
return &PreparedBinaryUsageError{Operation: "close", Reason: "is uninitialized"}
}
if prepared.closed {
return nil
}
if prepared.consumers != 0 {
return &PreparedBinaryUsageError{Operation: "close", Reason: "has active consumers"}
}
if err := prepared.workspace.Close(); err != nil {
return fmt.Errorf("close prepared agent workspace: %w", err)
}
prepared.closed = true
return nil
}
@@ -0,0 +1,101 @@
//go:build linux && agentcompat
package agent
import (
"context"
"errors"
"net"
"os"
"syscall"
"testing"
"time"
processharness "github.com/nezhahq/nezha/integration/agentcompat/internal/process"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workspace"
"github.com/stretchr/testify/require"
)
func TestPreparedBinary_ReleasesLeaseWhenOnlyWorkspaceCleanupFails(t *testing.T) {
prepared, err := PrepareBinary(t.Context(), testAgentSourceDir(t))
require.NoError(t, err)
_, release, err := prepared.acquire()
require.NoError(t, err)
workspaceRoot, err := workspace.New(context.WithoutCancel(t.Context()))
require.NoError(t, err)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
ownedListener, err := workspaceRoot.AdoptListener(listener)
require.NoError(t, err)
heldListener, err := ownedListener.ExtraFile()
require.NoError(t, err)
agent := &Agent{workspace: workspaceRoot, releaseBinary: release, releasePending: true, cleanupDone: make(chan struct{})}
stopErr := agent.Stop(t.Context())
require.Error(t, stopErr)
require.NoError(t, prepared.Close())
require.NoError(t, heldListener.Close())
require.NoError(t, workspaceRoot.Close())
require.NoDirExists(t, workspaceRoot.Root())
}
func TestPreparedBinary_RetainsLeaseWhileConsumerProcessGroupLivesAfterCleanupFailure(t *testing.T) {
prepared, err := PrepareBinary(t.Context(), testAgentSourceDir(t))
require.NoError(t, err)
binaryPath, release, err := prepared.acquire()
require.NoError(t, err)
workspaceRoot, err := workspace.New(context.WithoutCancel(t.Context()))
require.NoError(t, err)
supervisor := processharness.NewSupervisor(t.Context(), processharness.Spec{
Name: "prepared-binary-lingering-consumer", Path: "/bin/sh", Args: []string{"-c", "exec tail -f /dev/null"},
MaxLogBytes: 1024, TerminateTimeout: time.Second, KillTimeout: time.Second,
})
cancelledContext, cancel := context.WithCancel(t.Context())
cancel()
_ = supervisor.Stop(cancelledContext)
require.NoError(t, supervisor.Stop(t.Context()))
require.NoError(t, supervisor.Start())
pid := supervisor.PID()
pgid := supervisor.ProcessGroupID()
require.NoError(t, workspaceRoot.TrackPID(pid))
require.NoError(t, workspaceRoot.TrackProcessGroup(pgid))
agent := &Agent{
workspace: workspaceRoot, binaryPath: binaryPath, releaseBinary: release, releasePending: true,
cleanupDone: make(chan struct{}), processes: []*processGeneration{{supervisor: supervisor, identity: ProcessIdentity{PID: pid, ProcessGroupID: pgid}}},
}
t.Cleanup(func() {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
select {
case <-supervisor.Exited():
case <-time.After(5 * time.Second):
}
_ = workspaceRoot.Close()
_ = prepared.Close()
})
stopErr := agent.Stop(t.Context())
require.Error(t, stopErr)
firstStopError := stopErr.Error()
require.NoError(t, syscall.Kill(-pgid, 0))
require.FileExists(t, binaryPath)
closeErr := prepared.Close()
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, closeErr, &usageErr)
require.Equal(t, "has active consumers", usageErr.Reason)
require.NoError(t, syscall.Kill(-pgid, syscall.SIGKILL))
select {
case <-supervisor.Exited():
case <-time.After(5 * time.Second):
t.Fatal("lingering consumer process was not reaped")
}
require.True(t, errors.Is(syscall.Kill(-pgid, 0), syscall.ESRCH))
recoveryErr := agent.Stop(t.Context())
require.Error(t, recoveryErr)
require.Equal(t, firstStopError, recoveryErr.Error())
require.NoError(t, prepared.Close())
_, statErr := os.Stat(prepared.WorkspaceRoot())
require.ErrorIs(t, statErr, os.ErrNotExist)
}
@@ -0,0 +1,57 @@
//go:build linux && agentcompat
package agent
import (
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
func TestPreparedBinary_ConcurrentConsumersSharePathAndBlockClose(t *testing.T) {
prepared, err := PrepareBinary(t.Context(), testAgentSourceDir(t))
require.NoError(t, err)
t.Cleanup(func() { _ = prepared.Close() })
releases := make(chan func(), 8)
errorsChannel := make(chan error, 8)
var acquireGroup sync.WaitGroup
for range 8 {
acquireGroup.Add(1)
go func() {
defer acquireGroup.Done()
path, release, acquireErr := prepared.acquire()
if acquireErr == nil && path != prepared.BinaryPath() {
acquireErr = fmt.Errorf("acquired path %q differs from prepared path", path)
}
errorsChannel <- acquireErr
if acquireErr == nil {
releases <- release
}
}()
}
acquireGroup.Wait()
close(errorsChannel)
close(releases)
for acquireErr := range errorsChannel {
require.NoError(t, acquireErr)
}
closeErr := prepared.Close()
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, closeErr, &usageErr)
require.Equal(t, "has active consumers", usageErr.Reason)
var releaseGroup sync.WaitGroup
for release := range releases {
releaseGroup.Add(1)
go func() {
defer releaseGroup.Done()
release()
}()
}
releaseGroup.Wait()
require.NoError(t, prepared.Close())
}
@@ -0,0 +1,190 @@
//go:build linux && agentcompat
package agent
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestPreparedBinary_EightIndependentAgentsShareBinaryAndCleanUp(t *testing.T) {
prepared, err := PrepareBinary(t.Context(), testAgentSourceDir(t))
require.NoError(t, err)
preparedRoot := prepared.WorkspaceRoot()
binaryPath := prepared.BinaryPath()
require.FileExists(t, binaryPath)
initialBinaryInfo, err := os.Stat(binaryPath)
require.NoError(t, err)
initialBinaryStat, ok := initialBinaryInfo.Sys().(*syscall.Stat_t)
require.True(t, ok)
instances := make([]*Agent, 0, 8)
t.Cleanup(func() {
for _, instance := range instances {
cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
_ = instance.Stop(cleanupContext)
cancel()
}
_ = prepared.Close()
})
dashboardInstance := startTestDashboard(t, true)
configPaths := make(map[string]struct{}, 8)
logPaths := make(map[string]struct{}, 8)
for index := range 8 {
config := AgentStartConfig{
PreparedBinary: prepared,
Endpoint: dashboardInstance.TLSEndpoint(),
Secret: dashboardInstance.AgentSecret(),
UUID: preparedBinaryUUID(index),
TLS: true,
CAFilePath: dashboardInstance.TLSCACertificatePath(),
Debug: index%2 == 0,
}
if index == 6 {
config.FMObserverRunID = "prepared-binary-observer"
}
if index == 7 {
config.Credential = &syscall.Credential{Uid: 65534, Gid: 65534}
}
instance, startErr := Start(t.Context(), config)
require.NoError(t, startErr)
require.Equal(t, binaryPath, instance.BinaryPath())
processBinaryInfo, statErr := os.Stat(fmt.Sprintf("/proc/%d/exe", instance.PID()))
if errors.Is(statErr, syscall.EACCES) || errors.Is(statErr, syscall.EPERM) {
t.Logf("kernel denied /proc/%d/exe metadata for agent %d", instance.PID(), index)
} else {
require.NoError(t, statErr)
processBinaryStat, statOK := processBinaryInfo.Sys().(*syscall.Stat_t)
require.True(t, statOK)
require.Equal(t, initialBinaryStat.Dev, processBinaryStat.Dev)
require.Equal(t, initialBinaryStat.Ino, processBinaryStat.Ino)
}
require.NotEqual(t, preparedRoot, instance.WorkspaceRoot())
require.NoFileExists(t, filepath.Join(instance.WorkspaceRoot(), "bin", "agent"))
configPaths[instance.ConfigPath()] = struct{}{}
logPaths[instance.LogPath()] = struct{}{}
require.NoError(t, waitForAgentReady(t, instance, dashboardInstance))
if index == 6 {
require.NotNil(t, instance.FMProducerObserver())
require.FileExists(t, instance.fmObserverPath)
}
if index == 7 {
status, statusErr := os.ReadFile(fmt.Sprintf("/proc/%d/status", instance.PID()))
require.NoError(t, statusErr)
require.Contains(t, string(status), "Uid:\t65534\t65534\t65534\t65534")
}
instances = append(instances, instance)
}
finalBinaryInfo, err := os.Stat(binaryPath)
require.NoError(t, err)
require.True(t, os.SameFile(initialBinaryInfo, finalBinaryInfo))
require.Len(t, configPaths, 8)
require.Len(t, logPaths, 8)
closeErr := prepared.Close()
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, closeErr, &usageErr)
require.Equal(t, "close", usageErr.Operation)
require.Equal(t, "has active consumers", usageErr.Reason)
for index, instance := range instances {
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
require.NoError(t, instance.Stop(stopContext), "agent %d", index)
cancel()
require.True(t, instance.CleanupReceipt().Passed)
require.False(t, instance.CleanupReceipt().Forced)
if index < len(instances)-1 {
require.DirExists(t, preparedRoot)
require.FileExists(t, binaryPath)
}
}
require.NoError(t, prepared.Close())
require.NoError(t, prepared.Close())
_, statErr := os.Stat(preparedRoot)
require.ErrorIs(t, statErr, os.ErrNotExist)
}
func TestAgent_StartBuildsAndOwnsItsBinary(t *testing.T) {
instance, err := Start(t.Context(), AgentStartConfig{
SourceDir: testAgentSourceDir(t),
Endpoint: "127.0.0.1:1",
UUID: "00000000-0000-0000-0000-000000000197",
})
require.NoError(t, err)
workspaceRoot := instance.WorkspaceRoot()
require.Equal(t, filepath.Join(workspaceRoot, "bin", "agent"), instance.BinaryPath())
require.FileExists(t, instance.BinaryPath())
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, instance.Stop(stopContext))
_, statErr := os.Stat(workspaceRoot)
require.ErrorIs(t, statErr, os.ErrNotExist)
}
func TestPreparedBinary_RejectsConsumerAfterClose(t *testing.T) {
prepared, err := PrepareBinary(t.Context(), testAgentSourceDir(t))
require.NoError(t, err)
require.NoError(t, prepared.Close())
_, err = Start(t.Context(), AgentStartConfig{
PreparedBinary: prepared,
Endpoint: "127.0.0.1:1",
UUID: "00000000-0000-0000-0000-000000000198",
})
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, err, &usageErr)
require.Equal(t, "start", usageErr.Operation)
require.Equal(t, "is closed", usageErr.Reason)
}
func TestPreparedBinary_RejectsInvalidSourceDirectory(t *testing.T) {
_, err := PrepareBinary(t.Context(), "relative")
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, err, &usageErr)
require.Equal(t, "prepare", usageErr.Operation)
require.True(t, strings.Contains(usageErr.Reason, "must be absolute"))
}
func TestPreparedBinary_CancelledBuildRemovesWorkspace(t *testing.T) {
temporaryRoot := t.TempDir()
t.Setenv("TMPDIR", temporaryRoot)
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err := PrepareBinary(ctx, testAgentSourceDir(t))
require.Error(t, err)
entries, readErr := os.ReadDir(temporaryRoot)
require.NoError(t, readErr)
require.Empty(t, entries)
}
func TestPreparedBinary_RejectsUninitializedValue(t *testing.T) {
_, err := Start(t.Context(), AgentStartConfig{
PreparedBinary: &PreparedBinary{},
Endpoint: "127.0.0.1:1",
UUID: "00000000-0000-0000-0000-000000000199",
})
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, err, &usageErr)
require.Equal(t, "start", usageErr.Operation)
require.Equal(t, "is uninitialized", usageErr.Reason)
}
func TestPreparedBinary_CloseRejectsNilAndUninitializedValues(t *testing.T) {
var nilPrepared *PreparedBinary
for _, prepared := range []*PreparedBinary{nilPrepared, &PreparedBinary{}} {
err := prepared.Close()
var usageErr *PreparedBinaryUsageError
require.ErrorAs(t, err, &usageErr)
require.Equal(t, "close", usageErr.Operation)
}
}
func preparedBinaryUUID(index int) string {
return fmt.Sprintf("00000000-0000-0000-0000-%012d", 190+index)
}
@@ -0,0 +1,170 @@
//go:build linux
package agent
import (
"context"
"errors"
"fmt"
"strings"
"syscall"
"time"
processharness "github.com/nezhahq/nezha/integration/agentcompat/internal/process"
)
type ProcessIdentity struct {
Generation uint64
PID int
ProcessGroupID int
}
type ProcessTransition struct {
Previous ProcessIdentity
Current ProcessIdentity
}
type processGeneration struct {
supervisor *processharness.Supervisor
identity ProcessIdentity
record processharness.CleanupRecord
}
func (agent *Agent) RuntimeIdentity() ProcessIdentity {
agent.processMu.Lock()
defer agent.processMu.Unlock()
if agent.currentProcess == nil {
return ProcessIdentity{}
}
return agent.currentProcess.identity
}
func (agent *Agent) StartProcess(ctx context.Context) (ProcessTransition, error) {
agent.processMu.Lock()
defer agent.processMu.Unlock()
if agent.closed {
return ProcessTransition{}, errors.New("agent is closed")
}
if agent.currentProcess != nil {
return ProcessTransition{}, errors.New("agent process is already running")
}
agent.generation++
logFile, err := agent.workspace.Log(fmt.Sprintf("agent-%s-generation-%d", strings.ReplaceAll(agent.uuid, "-", ""), agent.generation))
if err != nil {
return ProcessTransition{}, err
}
agent.logPath = logFile.Name()
newSupervisor := processharness.NewSupervisor
if agent.startConfig.newSupervisor != nil {
newSupervisor = agent.startConfig.newSupervisor
}
supervisor := newSupervisor(ctx, processharness.Spec{
Name: "agent", Path: agent.binaryPath, Args: []string{"-c", agent.configPath}, Env: agent.environment,
Stdout: logFile, Stderr: logFile, MaxLogBytes: agentMaxLogBytes,
TerminateTimeout: agentStopTimeout, KillTimeout: agentKillTimeout,
Credential: agent.startConfig.Credential,
})
if err := supervisor.Start(); err != nil {
return ProcessTransition{}, err
}
identity := ProcessIdentity{Generation: agent.generation, PID: supervisor.PID(), ProcessGroupID: supervisor.ProcessGroupID()}
generation := &processGeneration{supervisor: supervisor, identity: identity, record: supervisor.CleanupRecord()}
// Register the started generation before post-start setup so failures remain cleanup-owned.
agent.currentProcess = generation
agent.supervisor = supervisor
agent.processes = append(agent.processes, generation)
if err := agent.trackPID(identity.PID); err != nil {
return agent.rollbackStartedProcess(ctx, generation, err)
}
if err := agent.trackProcessGroup(identity.ProcessGroupID); err != nil {
return agent.rollbackStartedProcess(ctx, generation, err)
}
previous := ProcessIdentity{}
return ProcessTransition{Previous: previous, Current: identity}, nil
}
func (agent *Agent) rollbackStartedProcess(ctx context.Context, generation *processGeneration, trackingErr error) (ProcessTransition, error) {
agent.currentProcess = nil
agent.supervisor = nil
rollbackContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
rollbackErr := generation.supervisor.Stop(rollbackContext)
generation.record = generation.supervisor.CleanupRecord()
return ProcessTransition{}, errors.Join(trackingErr, rollbackErr)
}
func (agent *Agent) StopProcess(ctx context.Context) (ProcessTransition, error) {
agent.processMu.Lock()
process := agent.currentProcess
if process == nil {
agent.processMu.Unlock()
return ProcessTransition{}, errors.New("agent process is not running")
}
agent.currentProcess = nil
agent.supervisor = nil
agent.processMu.Unlock()
if err := process.supervisor.Stop(ctx); err != nil {
return ProcessTransition{Previous: process.identity}, fmt.Errorf("stop agent process: %w", err)
}
process.record = process.supervisor.CleanupRecord()
return ProcessTransition{Previous: process.identity}, nil
}
func (agent *Agent) RestartProcess(ctx context.Context) (ProcessTransition, error) {
stopped, err := agent.StopProcess(ctx)
if err != nil {
return stopped, err
}
started, err := agent.StartProcess(ctx)
if err != nil {
return ProcessTransition{Previous: stopped.Previous}, err
}
return ProcessTransition{Previous: stopped.Previous, Current: started.Current}, nil
}
func (agent *Agent) Restart(ctx context.Context) error {
_, err := agent.RestartProcess(ctx)
return err
}
func (agent *Agent) Close(ctx context.Context) error {
agent.processMu.Lock()
agent.closed = true
agent.processMu.Unlock()
return agent.Stop(ctx)
}
func (agent *Agent) closeProcesses(ctx context.Context) error {
agent.processMu.Lock()
processes := append([]*processGeneration(nil), agent.processes...)
agent.processMu.Unlock()
var cleanupError error
for _, process := range processes {
stopContext, cancel := context.WithTimeout(ctx, 15*time.Second)
cleanupError = errors.Join(cleanupError, process.supervisor.Stop(stopContext))
cancel()
process.record = process.supervisor.CleanupRecord()
}
return cleanupError
}
func (agent *Agent) processesQuiescent() bool {
agent.processMu.Lock()
processes := append([]*processGeneration(nil), agent.processes...)
agent.processMu.Unlock()
for _, process := range processes {
select {
case <-process.supervisor.Exited():
default:
return false
}
err := syscall.Kill(-process.identity.ProcessGroupID, 0)
if err == nil || errors.Is(err, syscall.EPERM) {
return false
}
if !errors.Is(err, syscall.ESRCH) {
return false
}
}
return true
}
@@ -0,0 +1,142 @@
//go:build linux && agentcompat
package agent
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workspace"
)
func TestAgent_StartProcess_tracksStartedGenerationForStop(t *testing.T) {
agent := newUnstartedTestAgent(t)
transition, err := agent.StartProcess(t.Context())
require.NoError(t, err)
require.NotZero(t, transition.Current.PID)
require.Equal(t, transition.Current, agent.RuntimeIdentity())
require.NoError(t, agent.Stop(t.Context()))
}
func TestAgent_StartProcess_rollsBackStartedGenerationWhenPIDTrackingFails(t *testing.T) {
agent := newUnstartedTestAgent(t)
trackingErr := errors.New("injected PID tracking failure")
var startedPID int
agent.trackPID = func(pid int) error {
startedPID = pid
return trackingErr
}
_, err := agent.StartProcess(t.Context())
require.ErrorIs(t, err, trackingErr)
require.Empty(t, agent.RuntimeIdentity())
receipt := agent.CleanupReceipt()
require.Len(t, receipt.Processes, 1)
t.Logf("started_pid=%d started_pgid=%d injected_failure=%q runtime_identity=%+v cleanup_record=%+v forced=%t", startedPID, startedPID, trackingErr, agent.RuntimeIdentity(), receipt.Processes[0], receipt.Forced)
require.Equal(t, "agent", receipt.Processes[0].Name)
require.NotZero(t, receipt.Processes[0].PID)
require.False(t, receipt.Processes[0].Forced)
requireProcessAndGroupGone(t, receipt.Processes[0].PID, receipt.Processes[0].PID)
require.FileExists(t, agent.ConfigPath())
require.NoError(t, agent.Stop(t.Context()))
require.NoDirExists(t, agent.WorkspaceRoot())
}
func TestAgent_StartProcess_rollsBackStartedGenerationWhenProcessGroupTrackingFails(t *testing.T) {
agent := newUnstartedTestAgent(t)
trackingErr := errors.New("injected process group tracking failure")
var startedPID, startedProcessGroupID int
agent.trackPID = func(pid int) error {
startedPID = pid
return nil
}
agent.trackProcessGroup = func(processGroupID int) error {
startedProcessGroupID = processGroupID
return trackingErr
}
_, err := agent.StartProcess(t.Context())
require.ErrorIs(t, err, trackingErr)
require.Empty(t, agent.RuntimeIdentity())
receipt := agent.CleanupReceipt()
require.Len(t, receipt.Processes, 1)
t.Logf("started_pid=%d started_pgid=%d injected_failure=%q runtime_identity=%+v cleanup_record=%+v forced=%t", startedPID, startedProcessGroupID, trackingErr, agent.RuntimeIdentity(), receipt.Processes[0], receipt.Forced)
require.Equal(t, "agent", receipt.Processes[0].Name)
require.NotZero(t, receipt.Processes[0].PID)
require.False(t, receipt.Processes[0].Forced)
requireProcessAndGroupGone(t, receipt.Processes[0].PID, startedProcessGroupID)
require.FileExists(t, agent.ConfigPath())
require.NoError(t, agent.Stop(t.Context()))
require.NoDirExists(t, agent.WorkspaceRoot())
}
func newUnstartedTestAgent(t *testing.T) *Agent {
workspaceRoot, err := workspace.New(t.Context())
require.NoError(t, err)
agent := &Agent{workspace: workspaceRoot, uuid: "00000000-0000-0000-0000-000000000091", cleanupDone: make(chan struct{}), startConfig: AgentStartConfig{SourceDir: testAgentSourceDir(t), Endpoint: "127.0.0.1:1", Secret: agentSecret}, trackPID: workspaceRoot.TrackPID, trackProcessGroup: workspaceRoot.TrackProcessGroup}
require.NoError(t, agent.prepareFixture(t.Context(), agent.startConfig))
t.Cleanup(func() {
cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, agent.Stop(cleanupContext))
})
return agent
}
func requireProcessAndGroupGone(t *testing.T, pid, processGroupID int) {
_, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid)))
require.ErrorIs(t, err, os.ErrNotExist)
require.ErrorIs(t, syscall.Kill(-processGroupID, 0), syscall.ESRCH)
}
func TestAgent_StopProcessPreservesConfig(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, false)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{UUID: "00000000-0000-0000-0000-000000000089"})
require.NoError(t, waitForAgentReady(t, agentInstance, dashboardInstance))
configBefore, err := os.ReadFile(agentInstance.ConfigPath())
require.NoError(t, err)
pidBefore := agentInstance.PID()
// When
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err = agentInstance.StopProcess(stopContext)
// Then
require.NoError(t, err)
configAfter, readErr := os.ReadFile(agentInstance.ConfigPath())
require.NoError(t, readErr)
require.Equal(t, configBefore, configAfter)
require.Equal(t, "00000000-0000-0000-0000-000000000089", agentInstance.UUID())
require.NotZero(t, pidBefore)
}
func TestAgent_RestartProcessPreservesConfigBytesAndUUID(t *testing.T) {
// Given
dashboardInstance := startTestDashboard(t, false)
agentInstance := startTestAgent(t, dashboardInstance, AgentStartConfig{UUID: "00000000-0000-0000-0000-000000000090"})
require.NoError(t, waitForAgentReady(t, agentInstance, dashboardInstance))
configBefore, err := os.ReadFile(agentInstance.ConfigPath())
require.NoError(t, err)
pidBefore := agentInstance.PID()
// When
restartContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err = agentInstance.RestartProcess(restartContext)
// Then
require.NoError(t, err)
require.NotEqual(t, pidBefore, agentInstance.PID())
configAfter, readErr := os.ReadFile(agentInstance.ConfigPath())
require.NoError(t, readErr)
require.Equal(t, configBefore, configAfter)
require.Equal(t, "00000000-0000-0000-0000-000000000090", agentInstance.UUID())
}
@@ -0,0 +1,241 @@
//go:build linux
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/nezhahq/nezha/integration/agentcompat/internal/client"
"github.com/nezhahq/nezha/integration/agentcompat/internal/dashboard"
)
const readinessBudget = 45 * time.Second
type Readiness struct {
ServerID uint64
UUID string
Version string
Online bool
LastActive time.Time
VersionObserved bool
RequestTaskEstablished bool
StateReceiptObserved bool
Host json.RawMessage
State json.RawMessage
}
type serverListArguments struct {
OnlineOnly bool `json:"online_only"`
}
type serverListResult struct {
Servers []serverListItem `json:"servers"`
Count int `json:"count"`
}
type serverListItem struct {
ID uint64 `json:"id"`
UUID string `json:"uuid"`
Online bool `json:"online"`
Platform string `json:"platform"`
Arch string `json:"arch"`
LastActive time.Time `json:"last_active"`
}
type serverGetArguments struct {
ServerID uint64 `json:"server_id"`
}
type serverGetResult struct {
ID uint64 `json:"id"`
UUID string `json:"uuid"`
Host json.RawMessage `json:"host"`
State json.RawMessage `json:"state"`
LastActive time.Time `json:"last_active"`
}
type execArguments struct {
ServerID uint64 `json:"server_id"`
Cmd string `json:"cmd"`
Args []string `json:"args"`
}
type execResult struct {
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Error string `json:"error"`
}
func (agent *Agent) WaitReady(ctx context.Context, dashboardInstance *dashboard.Dashboard) (Readiness, error) {
deadline, cancel := context.WithTimeout(ctx, readinessBudget)
defer cancel()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
readiness, err := agent.probeReadiness(deadline, dashboardInstance)
if err == nil {
return readiness, nil
}
select {
case <-agent.supervisor.Exited():
return Readiness{}, fmt.Errorf("agent exited before readiness: %w", err)
case <-deadline.Done():
return Readiness{}, fmt.Errorf("agent readiness: %w", errors.Join(err, deadline.Err()))
case <-ticker.C:
}
}
}
func (agent *Agent) WaitReadyEventDriven(ctx context.Context, dashboardInstance *dashboard.Dashboard) (Readiness, error) {
serverID, err := dashboardInstance.WaitForInfo2UUID(ctx, agent.uuid)
if err != nil {
return Readiness{}, fmt.Errorf("agent info2 readiness: %w", err)
}
readiness, err := agent.probeReadinessForServer(ctx, dashboardInstance.Clients().MCP, serverID)
if err != nil {
return Readiness{}, err
}
return readiness, nil
}
func (agent *Agent) WaitReadyEventDrivenWithClient(ctx context.Context, dashboardInstance *dashboard.Dashboard, mcpClient *client.Client) (Readiness, error) {
serverID, err := dashboardInstance.WaitForInfo2UUID(ctx, agent.uuid)
if err != nil {
return Readiness{}, fmt.Errorf("agent info2 readiness: %w", err)
}
return agent.probeReadinessForServer(ctx, mcpClient, serverID)
}
func (agent *Agent) probeReadinessForServer(ctx context.Context, mcpClient *client.Client, serverID uint64) (Readiness, error) {
serverResponse, err := client.CallTool[serverGetArguments, serverGetResult](ctx, mcpClient, client.ToolCall[serverGetArguments]{Name: "server.get", Arguments: serverGetArguments{ServerID: serverID}})
if err != nil {
return Readiness{}, err
}
server := serverListItem{ID: serverID, UUID: agent.uuid, Online: true}
if err := verifyServerGetResult(server, serverResponse.StructuredContent); err != nil {
return Readiness{}, err
}
execResponse, err := client.CallTool[execArguments, execResult](ctx, mcpClient, client.ToolCall[execArguments]{Name: "server.exec", Arguments: execArguments{ServerID: serverID, Cmd: "sh", Args: []string{"-c", "printf agentcompat-ready"}}})
if err != nil {
return Readiness{}, fmt.Errorf("live RequestTask probe: %w", err)
}
if execResponse.StructuredContent.ExitCode != 0 || execResponse.StructuredContent.Stdout != "agentcompat-ready" {
return Readiness{}, errors.New("live RequestTask probe returned unexpected result")
}
version, versionObserved, err := decodeHostVersionEvidence(serverResponse.StructuredContent.Host)
if err != nil {
return Readiness{}, err
}
return Readiness{ServerID: serverID, UUID: agent.uuid, Version: version, Online: true, LastActive: serverResponse.StructuredContent.LastActive, VersionObserved: versionObserved, RequestTaskEstablished: true, StateReceiptObserved: true, Host: serverResponse.StructuredContent.Host, State: serverResponse.StructuredContent.State}, nil
}
func (agent *Agent) probeReadiness(ctx context.Context, dashboardInstance *dashboard.Dashboard) (Readiness, error) {
call := dashboardInstance.Clients().MCP
list, err := client.CallTool[serverListArguments, serverListResult](ctx, call, client.ToolCall[serverListArguments]{Name: "server.list", Arguments: serverListArguments{OnlineOnly: true}})
if err != nil {
return Readiness{}, err
}
var server serverListItem
for _, candidate := range list.StructuredContent.Servers {
if candidate.UUID == agent.uuid {
server = candidate
break
}
}
if server.ID == 0 || server.UUID != agent.uuid || !server.Online {
return Readiness{}, errors.New("agent UUID is not online in dashboard server.list")
}
execResponse, err := client.CallTool[execArguments, execResult](ctx, call, client.ToolCall[execArguments]{Name: "server.exec", Arguments: execArguments{ServerID: server.ID, Cmd: "sh", Args: []string{"-c", "printf agentcompat-ready"}}})
if err != nil {
return Readiness{}, fmt.Errorf("live RequestTask probe: %w", err)
}
if execResponse.StructuredContent.ExitCode != 0 || execResponse.StructuredContent.Stdout != "agentcompat-ready" {
return Readiness{}, errors.New("live RequestTask probe returned unexpected result")
}
serverResponse, err := client.CallTool[serverGetArguments, serverGetResult](ctx, call, client.ToolCall[serverGetArguments]{Name: "server.get", Arguments: serverGetArguments{ServerID: server.ID}})
if err != nil {
return Readiness{}, err
}
if err := verifyServerGetResult(server, serverResponse.StructuredContent); err != nil {
return Readiness{}, err
}
version, versionObserved, err := decodeHostVersionEvidence(serverResponse.StructuredContent.Host)
if err != nil {
return Readiness{}, err
}
stateReceiptObserved := dashboardInstance.ReceiptAccepted()
if !dashboardInstance.ReceiptGateEnabled() {
stateReceiptObserved = agent.observeStateReceipt(serverResponse.StructuredContent.LastActive)
}
if !stateReceiptObserved {
return Readiness{}, errors.New("waiting for a second state report after receipt")
}
return Readiness{
ServerID: server.ID, UUID: agent.uuid, Version: version, Online: true, LastActive: serverResponse.StructuredContent.LastActive, VersionObserved: versionObserved,
RequestTaskEstablished: true, StateReceiptObserved: stateReceiptObserved,
Host: serverResponse.StructuredContent.Host, State: serverResponse.StructuredContent.State,
}, nil
}
func decodeHostVersionEvidence(raw json.RawMessage) (string, bool, error) {
var host struct {
Version string `json:"version"`
}
if err := json.Unmarshal(raw, &host); err != nil {
return "", false, fmt.Errorf("decode dashboard Host: %w", err)
}
// A decoded Host object is not version evidence unless the Agent reported a value.
return host.Version, host.Version != "", nil
}
func verifyServerGetResult(server serverListItem, result serverGetResult) error {
if server.ID == 0 || result.ID != server.ID || result.UUID != server.UUID {
return errors.New("dashboard server.get identity does not match server.list")
}
if len(result.Host) == 0 || len(result.State) == 0 || string(result.Host) == "null" || string(result.State) == "null" {
return errors.New("dashboard server.get omitted Host or State")
}
return nil
}
func (agent *Agent) observeStateReceipt(lastActive time.Time) bool {
agent.readinessMu.Lock()
defer agent.readinessMu.Unlock()
observed := !agent.lastStateReport.IsZero() && lastActive.After(agent.lastStateReport)
if lastActive.After(agent.lastStateReport) {
agent.lastStateReport = lastActive
}
return observed
}
func (agent *Agent) AssertNeverOnline(ctx context.Context, dashboardInstance *dashboard.Dashboard, duration time.Duration) error {
deadline, cancel := context.WithTimeout(ctx, duration)
defer cancel()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
var lastError error
for {
list, err := client.CallTool[serverListArguments, serverListResult](deadline, dashboardInstance.Clients().MCP, client.ToolCall[serverListArguments]{Name: "server.list", Arguments: serverListArguments{OnlineOnly: true}})
if err == nil {
for _, server := range list.StructuredContent.Servers {
if server.UUID == agent.uuid {
return errors.New("invalid-secret agent became online")
}
}
} else if deadline.Err() == nil {
lastError = err
}
select {
case <-deadline.Done():
if lastError != nil {
return fmt.Errorf("server.list unavailable while asserting agent stayed offline: %w", lastError)
}
return nil
case <-ticker.C:
}
}
}
@@ -0,0 +1,47 @@
//go:build linux && agentcompat
package agent
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestServerGetResult_VerifiesListedServerIdentity_whenUUIDOrIDMismatch(t *testing.T) {
listed := serverListItem{ID: 81, UUID: "00000000-0000-0000-0000-000000000081", Online: true}
tests := []struct {
name string
result serverGetResult
}{
{
name: "UUID differs",
result: serverGetResult{ID: listed.ID, UUID: "00000000-0000-0000-0000-000000000082", Host: json.RawMessage(`{}`), State: json.RawMessage(`{}`)},
},
{
name: "ID differs",
result: serverGetResult{ID: 82, UUID: listed.UUID, Host: json.RawMessage(`{}`), State: json.RawMessage(`{}`)},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := test.result
err := verifyServerGetResult(listed, result)
require.Error(t, err)
})
}
}
func TestServerGetResult_RejectsZeroListedServerID_whenReturnedIdentityMatches(t *testing.T) {
// Given
listed := serverListItem{UUID: "00000000-0000-0000-0000-000000000081", Online: true}
result := serverGetResult{UUID: listed.UUID, Host: json.RawMessage(`{}`), State: json.RawMessage(`{}`)}
// When
err := verifyServerGetResult(listed, result)
// Then
require.Error(t, err)
require.EqualError(t, err, "dashboard server.get identity does not match server.list")
}
@@ -0,0 +1,36 @@
//go:build linux
package agent
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestHostVersionEvidence_IsNotObserved_whenReportedVersionIsEmpty(t *testing.T) {
// Given
host := json.RawMessage(`{"version":""}`)
// When
version, observed, err := decodeHostVersionEvidence(host)
// Then
require.NoError(t, err)
require.Empty(t, version)
require.False(t, observed)
}
func TestHostVersionEvidence_IsObserved_whenReportedVersionIsNonempty(t *testing.T) {
// Given
host := json.RawMessage(`{"version":"v2.1.0"}`)
// When
version, observed, err := decodeHostVersionEvidence(host)
// Then
require.NoError(t, err)
require.Equal(t, "v2.1.0", version)
require.True(t, observed)
}