mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
2a7019b0fa
Proxmox planning and dispatch now require a unique credential-admitted typed runner with durable receipts. Development authentication preserves explicit bearer identity so runner activation keeps its tenant and credential scope. Preserve observer and receiver timestamps from their separate clocks instead of rejecting or rewriting valid evidence. Keep completed execution separate from stale or inconclusive verification, and label independent observations accurately in action reviews. Verified with targeted race suites, action-review tests and frontend build, plus a real Assistant start plan and approved VM110 start/stop with independent Proxmox confirmation. Final action reviews passed Playwright at 1440, 900 and 390 pixels, including retained completed, rejected and expired history.
557 lines
18 KiB
Go
557 lines
18 KiB
Go
package agentexec
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHostStorageCleanupPayloadIsClosedAndSelectorFree(t *testing.T) {
|
|
typeOf := reflect.TypeOf(HostStorageCleanupPayload{})
|
|
want := []string{"RequestID", "ActionID", "Operation", "OperationVersion", "RequestDigest", "ExpectedFingerprint", "Timeout"}
|
|
if typeOf.NumField() != len(want) {
|
|
t.Fatalf("host storage cleanup payload fields = %d, want %d", typeOf.NumField(), len(want))
|
|
}
|
|
for i, name := range want {
|
|
if field := typeOf.Field(i); field.Name != name {
|
|
t.Fatalf("field %d = %s, want %s", i, field.Name, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestActionRefusalReasonCodeContractAndAlreadySatisfiedCleanup(t *testing.T) {
|
|
for _, code := range []string{
|
|
ActionRefusalTargetStateChanged,
|
|
ActionRefusalPackageManagerBusy,
|
|
ActionRefusalContractInvalid,
|
|
} {
|
|
if !IsActionRefusalReasonCode(code) {
|
|
t.Fatalf("stable refusal code %q was rejected", code)
|
|
}
|
|
}
|
|
for _, code := range []string{"", "Target Changed", strings.Repeat("a", 65)} {
|
|
if IsActionRefusalReasonCode(code) {
|
|
t.Fatalf("unsafe refusal code %q was accepted", code)
|
|
}
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
result := HostStorageCleanupResultPayload{
|
|
RequestID: "attempt-1", ActionID: "action-1", Success: true,
|
|
ExecutionPhase: HostStorageCleanupPhaseComplete,
|
|
Verification: HostStorageCleanupVerificationVerified,
|
|
Before: HostStorageCleanupSnapshot{
|
|
Supported: true, Provider: "apt-package-cache", Fingerprint: "sha256:" + strings.Repeat("a", 64), CheckedAt: now,
|
|
},
|
|
After: HostStorageCleanupSnapshot{
|
|
Supported: true, Provider: "apt-package-cache", Fingerprint: "sha256:" + strings.Repeat("a", 64), CheckedAt: now,
|
|
},
|
|
}
|
|
if err := ValidateHostStorageCleanupResultPayload(&result); err != nil {
|
|
t.Fatalf("already-satisfied cleanup should be a verified no-op: %v", err)
|
|
}
|
|
result.ReasonCode = ActionRefusalCleanupPreflightFailed
|
|
if err := ValidateHostStorageCleanupResultPayload(&result); err == nil {
|
|
t.Fatal("a successful result carried a refusal reason")
|
|
}
|
|
}
|
|
|
|
func allowAllTestTokens(string, string, string) bool { return true }
|
|
|
|
func TestActionRunnerHostResolutionUsesLiveTenantAdmission(t *testing.T) {
|
|
s := NewServerWithAdmissionValidator(func(string, string, string) (AgentAdmission, bool) {
|
|
return AgentAdmission{}, false
|
|
}, func(a AgentAdmission) bool { return a.TokenID != "revoked" })
|
|
add := func(org, id, host, role, capability, token string) *agentConn {
|
|
ac := &agentConn{
|
|
admission: AgentAdmission{OrganizationID: org, AgentID: id, Hostname: host, RuntimeRole: role, ActionCapability: capability, TokenID: token},
|
|
agent: ConnectedAgent{AgentID: id, Hostname: host, RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, OperationReceiptVersion: 1},
|
|
done: make(chan struct{}),
|
|
}
|
|
s.agents[agentSessionKey(org, id)] = ac
|
|
return ac
|
|
}
|
|
// Self-reported role/capability must not override credential admission.
|
|
add("org-a", "legacy", "delly", RuntimeRoleLegacyFullTrust, "", "legacy")
|
|
add("org-a", "unsupported", "delly", RuntimeRoleActionRunner, "unknown", "unsupported")
|
|
add("org-a", "revoked", "delly", RuntimeRoleActionRunner, ActionCapabilityTypedV1, "revoked")
|
|
add("org-b", "other-tenant", "delly", RuntimeRoleActionRunner, ActionCapabilityTypedV1, "valid")
|
|
if _, err := s.ExecuteProxmoxGuestLifecycle(WithOrganizationID(context.Background(), "org-a"), "legacy", boundProxmoxGuestLifecycle(t)); err == nil || !strings.Contains(err.Error(), "requires a typed action-runner session") {
|
|
t.Fatalf("legacy session reached typed dispatch: %v", err)
|
|
}
|
|
if id, ok := s.GetActionRunnerForHostForOrganization("org-a", "delly"); ok || id != "" {
|
|
t.Fatalf("non-admitted runner resolved: %q, %v", id, ok)
|
|
}
|
|
valid := add("org-a", "runner", "delly.home", RuntimeRoleActionRunner, ActionCapabilityTypedV1, "valid")
|
|
if id, ok := s.GetActionRunnerForHostForOrganization("org-a", "delly"); !ok || id != "runner" {
|
|
t.Fatalf("typed runner not resolved through canonical hostname: %q, %v", id, ok)
|
|
}
|
|
add("org-a", "ambiguous", "delly.home", RuntimeRoleActionRunner, ActionCapabilityTypedV1, "valid")
|
|
if id, ok := s.GetActionRunnerForHostForOrganization("org-a", "delly"); ok || id != "" {
|
|
t.Fatalf("ambiguous runner resolved: %q, %v", id, ok)
|
|
}
|
|
delete(s.agents, agentSessionKey("org-a", "ambiguous"))
|
|
s.actionRunnerPromotionFences[agentSessionKey("org-a", "runner")] = &ActionRunnerSessionPromotion{}
|
|
if id, ok := s.GetActionRunnerForHostForOrganization("org-a", "delly"); ok || id != "" {
|
|
t.Fatalf("fenced runner resolved: %q, %v", id, ok)
|
|
}
|
|
delete(s.actionRunnerPromotionFences, agentSessionKey("org-a", "runner"))
|
|
delete(s.agents, agentSessionKey("org-a", "runner"))
|
|
s.pendingActionRunners[agentSessionKey("org-a", "runner")] = valid
|
|
if id, ok := s.GetActionRunnerForHostForOrganization("org-a", "delly"); ok || id != "" {
|
|
t.Fatalf("pending runner resolved: %q, %v", id, ok)
|
|
}
|
|
if id, ok := s.GetActionRunnerForHostForOrganization("org-b", "delly"); !ok || id != "other-tenant" {
|
|
t.Fatalf("tenant isolation lost: %q, %v", id, ok)
|
|
}
|
|
}
|
|
|
|
func TestNewServerRequiresValidateToken(t *testing.T) {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("expected panic when validateToken is nil")
|
|
}
|
|
}()
|
|
|
|
_ = NewServer(nil)
|
|
}
|
|
|
|
func TestExecuteCommandAgentNotConnected(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
_, err := s.ExecuteCommand(context.Background(), "missing", ExecuteCommandPayload{RequestID: "r1", Timeout: 1})
|
|
if err == nil {
|
|
t.Fatalf("expected error when agent not connected")
|
|
}
|
|
}
|
|
|
|
func TestReadFileAgentNotConnected(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
_, err := s.ReadFile(context.Background(), "missing", ReadFilePayload{RequestID: "r1"})
|
|
if err == nil {
|
|
t.Fatalf("expected error when agent not connected")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCommandValidation(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
if _, err := s.ExecuteCommand(context.Background(), "", ExecuteCommandPayload{RequestID: "r1"}); err == nil {
|
|
t.Fatalf("expected empty agent id error")
|
|
}
|
|
if _, err := s.ExecuteCommand(context.Background(), "a1", ExecuteCommandPayload{}); err == nil {
|
|
t.Fatalf("expected empty request id error")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCommandSecurityValidation(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
|
|
cases := []struct {
|
|
name string
|
|
payload ExecuteCommandPayload
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing command",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
},
|
|
wantErr: "command is required",
|
|
},
|
|
{
|
|
name: "invalid target type",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "echo ok",
|
|
TargetType: "node",
|
|
},
|
|
wantErr: "invalid target type",
|
|
},
|
|
{
|
|
name: "unsupported host target type rejected",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "echo ok",
|
|
TargetType: "host",
|
|
},
|
|
wantErr: `invalid target type "host"`,
|
|
},
|
|
{
|
|
name: "container requires target id",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "echo ok",
|
|
TargetType: "container",
|
|
},
|
|
wantErr: "target id is required",
|
|
},
|
|
{
|
|
name: "invalid target id characters",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "echo ok",
|
|
TargetType: "vm",
|
|
TargetID: "100; rm -rf /",
|
|
},
|
|
wantErr: "target id contains invalid characters",
|
|
},
|
|
{
|
|
name: "negative timeout",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "echo ok",
|
|
Timeout: -1,
|
|
},
|
|
wantErr: "timeout cannot be negative",
|
|
},
|
|
{
|
|
name: "excessive timeout",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "echo ok",
|
|
Timeout: maxExecuteCommandTimeoutSeconds + 1,
|
|
},
|
|
wantErr: "timeout cannot exceed",
|
|
},
|
|
{
|
|
name: "request id too long",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: strings.Repeat("a", maxRequestIDLength+1),
|
|
Command: "echo ok",
|
|
},
|
|
wantErr: "request id exceeds",
|
|
},
|
|
{
|
|
name: "command too long",
|
|
payload: ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: strings.Repeat("a", maxExecuteCommandLength+1),
|
|
},
|
|
wantErr: "command exceeds",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := s.ExecuteCommand(context.Background(), "a1", tc.payload)
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteCommandSecurityValidation_RequiresApprovalIDWhenPolicyRequiresApproval(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
serverConn, _, cleanup := newConnPair(t)
|
|
defer cleanup()
|
|
|
|
ac := &agentConn{
|
|
conn: serverConn,
|
|
agent: ConnectedAgent{AgentID: "a1"},
|
|
done: make(chan struct{}),
|
|
}
|
|
s.mu.Lock()
|
|
s.agents["a1"] = ac
|
|
s.mu.Unlock()
|
|
|
|
_, err := s.ExecuteCommand(context.Background(), "a1", ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "echo ok",
|
|
Timeout: 1,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "requires approval") {
|
|
t.Fatalf("expected approval-required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCommandPayload_TrustedBypassesApproval(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
|
|
// A command in the require-approval list (docker exec wraps Discovery probes).
|
|
cmd := ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "docker exec pbs sh -c 'cat /etc/os-release'",
|
|
Timeout: 1,
|
|
}
|
|
|
|
if err := s.authorizeCommandPayload(cmd); err == nil {
|
|
t.Fatalf("baseline: expected approval-required error without Trusted, got nil")
|
|
}
|
|
|
|
cmd.Trusted = true
|
|
if err := s.authorizeCommandPayload(cmd); err != nil {
|
|
t.Fatalf("Trusted payload should bypass approval gate, got %v", err)
|
|
}
|
|
|
|
// Blocked commands are still blocked even when Trusted (defense in depth).
|
|
blocked := ExecuteCommandPayload{
|
|
RequestID: "r2",
|
|
Command: "rm -rf /",
|
|
Trusted: true,
|
|
Timeout: 1,
|
|
}
|
|
if err := s.authorizeCommandPayload(blocked); err == nil || !strings.Contains(err.Error(), "blocked by policy") {
|
|
t.Fatalf("Trusted must not bypass PolicyBlock; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteCommand_TrustedSkipsApprovalGrantMint(t *testing.T) {
|
|
// Regression for the auto-grant path: even when the policy says
|
|
// RequireApproval, a Trusted command without an ApprovalID must not
|
|
// trigger NewCommandApprovalGrant (which errors "approval id is
|
|
// required"). The grant is only meaningful for user-driven approvals.
|
|
s := NewServer(allowAllTestTokens)
|
|
serverConn, _, cleanup := newConnPair(t)
|
|
defer cleanup()
|
|
ac := &agentConn{
|
|
conn: serverConn,
|
|
agent: ConnectedAgent{AgentID: "a1"},
|
|
approvalGrantKey: []byte("not-empty-so-the-mint-path-is-reachable"),
|
|
done: make(chan struct{}),
|
|
writeMu: sync.Mutex{},
|
|
}
|
|
s.mu.Lock()
|
|
s.agents["a1"] = ac
|
|
s.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
_, err := s.ExecuteCommand(ctx, "a1", ExecuteCommandPayload{
|
|
RequestID: "r1",
|
|
Command: "docker exec pbs sh -c 'cat /etc/os-release'",
|
|
Trusted: true,
|
|
Timeout: 1,
|
|
})
|
|
// Expected: command is dispatched and we time out waiting for a result
|
|
// from the fake conn (no agent code on the other side). What must NOT
|
|
// appear is the "approval id is required" / "failed to issue approval
|
|
// grant" mint failure.
|
|
if err != nil && (strings.Contains(err.Error(), "failed to issue approval grant") || strings.Contains(err.Error(), "approval id is required")) {
|
|
t.Fatalf("Trusted command must skip auto-grant mint, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadFileValidation(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
if _, err := s.ReadFile(context.Background(), "", ReadFilePayload{RequestID: "r1"}); err == nil {
|
|
t.Fatalf("expected empty agent id error")
|
|
}
|
|
if _, err := s.ReadFile(context.Background(), "a1", ReadFilePayload{}); err == nil {
|
|
t.Fatalf("expected empty request id error")
|
|
}
|
|
}
|
|
|
|
func TestReadFileSecurityValidation(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
|
|
cases := []struct {
|
|
name string
|
|
payload ReadFilePayload
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing path",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
},
|
|
wantErr: "path is required",
|
|
},
|
|
{
|
|
name: "path with control character",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
Path: "/etc/\x00passwd",
|
|
},
|
|
wantErr: "invalid control characters",
|
|
},
|
|
{
|
|
name: "invalid target type",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
Path: "/etc/passwd",
|
|
TargetType: "node",
|
|
},
|
|
wantErr: "invalid target type",
|
|
},
|
|
{
|
|
name: "unsupported host target type rejected",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
Path: "/etc/passwd",
|
|
TargetType: "host",
|
|
},
|
|
wantErr: `invalid target type "host"`,
|
|
},
|
|
{
|
|
name: "container requires target id",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
Path: "/etc/passwd",
|
|
TargetType: "container",
|
|
},
|
|
wantErr: "target id is required",
|
|
},
|
|
{
|
|
name: "invalid target id characters",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
Path: "/etc/passwd",
|
|
TargetType: "vm",
|
|
TargetID: "101; reboot",
|
|
},
|
|
wantErr: "target id contains invalid characters",
|
|
},
|
|
{
|
|
name: "negative max bytes",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
Path: "/etc/passwd",
|
|
MaxBytes: -1,
|
|
},
|
|
wantErr: "max bytes cannot be negative",
|
|
},
|
|
{
|
|
name: "max bytes too large",
|
|
payload: ReadFilePayload{
|
|
RequestID: "r1",
|
|
Path: "/etc/passwd",
|
|
MaxBytes: maxReadFileMaxBytes + 1,
|
|
},
|
|
wantErr: "max bytes cannot exceed",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := s.ReadFile(context.Background(), "a1", tc.payload)
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConnectedAgentLookups(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
now := time.Now().Add(-1 * time.Minute)
|
|
|
|
s.mu.Lock()
|
|
s.agents["a1"] = &agentConn{agent: ConnectedAgent{AgentID: "a1", Hostname: "host1", ConnectedAt: now}}
|
|
s.agents["a2"] = &agentConn{agent: ConnectedAgent{AgentID: "a2", Hostname: "host2", ConnectedAt: now}}
|
|
s.mu.Unlock()
|
|
|
|
if !s.IsAgentConnected("a1") {
|
|
t.Fatalf("expected a1 to be connected")
|
|
}
|
|
if s.IsAgentConnected("missing") {
|
|
t.Fatalf("expected missing to not be connected")
|
|
}
|
|
|
|
agentID, ok := s.GetAgentForHost("host2")
|
|
if !ok || agentID != "a2" {
|
|
t.Fatalf("expected GetAgentForHost(host2) = (a2, true), got (%q, %v)", agentID, ok)
|
|
}
|
|
if _, ok := s.GetAgentForHost("missing"); ok {
|
|
t.Fatalf("expected missing host to return false")
|
|
}
|
|
|
|
agents := s.GetConnectedAgents()
|
|
if len(agents) != 2 {
|
|
t.Fatalf("expected 2 connected agents, got %d", len(agents))
|
|
}
|
|
}
|
|
|
|
func TestGetAgentForIdentityForOrganizationRequiresOneExactLiveAdmission(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
s.mu.Lock()
|
|
s.agents[agentSessionKey("org-a", "a1")] = &agentConn{
|
|
agent: ConnectedAgent{AgentID: "a1", Hostname: "node.example.com"},
|
|
admission: AgentAdmission{OrganizationID: "org-a", TokenID: "rotated-token", AgentID: "a1", Hostname: "node.example.com"},
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if got, ok := s.GetAgentForIdentityForOrganization("org-a", "a1", "NODE"); !ok || got != "a1" {
|
|
t.Fatalf("exact identity lookup = %q, %v; want a1, true", got, ok)
|
|
}
|
|
for _, mismatch := range []struct{ org, agentID, hostname string }{
|
|
{"org-b", "a1", "node"},
|
|
{"org-a", "a2", "node"},
|
|
{"org-a", "a1", "other"},
|
|
{"org-a", "", "node"},
|
|
{"org-a", "a1", ""},
|
|
} {
|
|
if got, ok := s.GetAgentForIdentityForOrganization(mismatch.org, mismatch.agentID, mismatch.hostname); ok {
|
|
t.Fatalf("mismatched identity unexpectedly resolved %q for %#v", got, mismatch)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetAgentForHostNormalizesFQDNAndCase(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
now := time.Now().Add(-1 * time.Minute)
|
|
|
|
s.mu.Lock()
|
|
s.agents["a1"] = &agentConn{agent: ConnectedAgent{AgentID: "a1", Hostname: "prox97.seftic.local", ConnectedAt: now}}
|
|
s.mu.Unlock()
|
|
|
|
cases := []string{"prox97", "PROX97", "prox97.seftic.local", "Prox97.Seftic.Local"}
|
|
for _, lookup := range cases {
|
|
agentID, ok := s.GetAgentForHost(lookup)
|
|
if !ok || agentID != "a1" {
|
|
t.Errorf("GetAgentForHost(%q) = (%q, %v), want (a1, true)", lookup, agentID, ok)
|
|
}
|
|
}
|
|
|
|
if _, ok := s.GetAgentForHost(""); ok {
|
|
t.Errorf("GetAgentForHost(\"\") should return false")
|
|
}
|
|
}
|
|
|
|
func TestGetAgentForHostKeepsDistinctFQDNsSeparate(t *testing.T) {
|
|
s := NewServer(allowAllTestTokens)
|
|
now := time.Now().Add(-1 * time.Minute)
|
|
|
|
s.mu.Lock()
|
|
s.agents["a1"] = &agentConn{agent: ConnectedAgent{AgentID: "a1", Hostname: "prox97.a.local", ConnectedAt: now}}
|
|
s.agents["a2"] = &agentConn{agent: ConnectedAgent{AgentID: "a2", Hostname: "prox97.b.local", ConnectedAt: now}}
|
|
s.mu.Unlock()
|
|
|
|
agentID, ok := s.GetAgentForHost("prox97.a.local")
|
|
if !ok || agentID != "a1" {
|
|
t.Fatalf("GetAgentForHost(%q) = (%q, %v), want (a1, true)", "prox97.a.local", agentID, ok)
|
|
}
|
|
|
|
agentID, ok = s.GetAgentForHost("prox97.b.local")
|
|
if !ok || agentID != "a2" {
|
|
t.Fatalf("GetAgentForHost(%q) = (%q, %v), want (a2, true)", "prox97.b.local", agentID, ok)
|
|
}
|
|
}
|
|
|
|
func TestPreparedActionRunnerSessionIsNotDispatchableUntilPromoted(t *testing.T) {
|
|
s := NewServerWithAdmissionValidator(func(string, string, string) (AgentAdmission, bool) {
|
|
return AgentAdmission{}, false
|
|
}, func(AgentAdmission) bool { return true })
|
|
admission := AgentAdmission{
|
|
OrganizationID: "org-a", TokenID: "pending-token", AgentID: "agent-1",
|
|
Hostname: "host-1.local", RuntimeRole: RuntimeRoleActionRunner,
|
|
ActionCapability: ActionCapabilityTypedV1, ActivationPending: true,
|
|
}
|
|
key := agentSessionKey(admission.OrganizationID, admission.AgentID)
|
|
s.pendingActionRunners[key] = &agentConn{admission: admission, agent: ConnectedAgent{AgentID: admission.AgentID}, done: make(chan struct{})}
|
|
if _, ok := s.connectionForOrganization("org-a", "agent-1"); ok {
|
|
t.Fatal("prepared runner was dispatchable")
|
|
}
|
|
if !s.HasActionRunnerSession(admission) {
|
|
t.Fatal("exact prepared transport was not available to activation")
|
|
}
|
|
if !s.PromoteActionRunnerSession(admission) {
|
|
t.Fatal("exact prepared transport was not promoted")
|
|
}
|
|
if _, ok := s.connectionForOrganization("org-a", "agent-1"); !ok {
|
|
t.Fatal("promoted runner did not become dispatchable")
|
|
}
|
|
}
|