mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 16:54:48 +00:00
Exercise pending runner activation in systemd lab
This commit is contained in:
parent
98e8f86806
commit
65bec55229
2 changed files with 122 additions and 5 deletions
|
|
@ -60,6 +60,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionrunner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
|
||||
)
|
||||
|
|
@ -155,15 +156,17 @@ type secureRuntimeLabFixture struct {
|
|||
actionServer *agentexec.Server
|
||||
actionSecret string
|
||||
actionBindingID string
|
||||
actionPending bool
|
||||
actionRevoked bool
|
||||
actionRevokes int
|
||||
actionActivations int
|
||||
authorityReductions int
|
||||
}
|
||||
|
||||
func newSecureRuntimeLabFixture(collector, helper, runner []byte, version string) *secureRuntimeLabFixture {
|
||||
fixture := &secureRuntimeLabFixture{
|
||||
collector: collector, helper: helper, runner: runner, serverVersion: version,
|
||||
actionSecret: secureRuntimeRunnerSecretV1, actionBindingID: secureRuntimeRunnerBindingV1,
|
||||
actionSecret: secureRuntimeRunnerSecretV1, actionBindingID: secureRuntimeRunnerBindingV1, actionPending: true,
|
||||
}
|
||||
fixture.actionServer = agentexec.NewServerWithAdmissionValidator(fixture.admitActionRunner, fixture.validateActionRunnerSession)
|
||||
return fixture
|
||||
|
|
@ -189,6 +192,7 @@ func (f *secureRuntimeLabFixture) actionAdmissionLocked() agentexec.AgentAdmissi
|
|||
OrganizationID: secureRuntimeLabOrgID, TokenID: f.actionBindingID,
|
||||
AgentID: secureRuntimeLabAgentID, Hostname: secureRuntimeLabHostname,
|
||||
RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: agentexec.ActionCapabilityTypedV1,
|
||||
ActivationPending: f.actionPending,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +202,7 @@ func (f *secureRuntimeLabFixture) replaceActionCredential(secret, bindingID stri
|
|||
previous := f.actionAdmissionLocked()
|
||||
f.actionSecret = secret
|
||||
f.actionBindingID = bindingID
|
||||
f.actionPending = true
|
||||
f.actionRevoked = false
|
||||
return previous
|
||||
}
|
||||
|
|
@ -208,6 +213,12 @@ func (f *secureRuntimeLabFixture) actionSnapshot() (agentexec.AgentAdmission, bo
|
|||
return f.actionAdmissionLocked(), f.actionRevoked, f.actionRevokes
|
||||
}
|
||||
|
||||
func (f *secureRuntimeLabFixture) actionActivationCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.actionActivations
|
||||
}
|
||||
|
||||
func (f *secureRuntimeLabFixture) authorityReductionCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -254,7 +265,7 @@ func (f *secureRuntimeLabFixture) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||
case r.URL.Path == "/api/agent/ws":
|
||||
f.actionServer.HandleWebSocket(w, r)
|
||||
case r.URL.Path == "/api/agents/action-runner/credential":
|
||||
f.handleActionRunnerSelfRevoke(w, r)
|
||||
f.handleActionRunnerCredential(w, r)
|
||||
case r.URL.Path == "/api/agents/collector/reduce-authority":
|
||||
f.handleCollectorAuthorityReduction(w, r)
|
||||
case r.URL.Path == "/api/health":
|
||||
|
|
@ -347,6 +358,58 @@ func (f *secureRuntimeLabFixture) serveArtifact(w http.ResponseWriter, r *http.R
|
|||
}
|
||||
}
|
||||
|
||||
func (f *secureRuntimeLabFixture) handleActionRunnerCredential(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPatch:
|
||||
f.handleActionRunnerActivation(w, r)
|
||||
case http.MethodDelete:
|
||||
f.handleActionRunnerSelfRevoke(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", http.MethodPatch+", "+http.MethodDelete)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *secureRuntimeLabFixture) handleActionRunnerActivation(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
AgentID string `json:"agentId"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
bearer := strings.TrimPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
|
||||
f.mu.Lock()
|
||||
admission := f.actionAdmissionLocked()
|
||||
valid := !f.actionRevoked && bearer == f.actionSecret && strings.TrimSpace(request.AgentID) == admission.AgentID && strings.EqualFold(strings.TrimSpace(request.Hostname), admission.Hostname)
|
||||
f.mu.Unlock()
|
||||
if !valid {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !f.actionServer.HasActionRunnerSession(admission) {
|
||||
http.Error(w, "exact action runner session is not registered", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if admission.ActivationPending {
|
||||
if !f.actionServer.PromoteActionRunnerSession(admission) {
|
||||
http.Error(w, "action runner session promotion failed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.actionBindingID != admission.TokenID || f.actionSecret != bearer || f.actionRevoked {
|
||||
f.mu.Unlock()
|
||||
http.Error(w, "action runner credential changed during activation", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
f.actionPending = false
|
||||
f.actionActivations++
|
||||
f.mu.Unlock()
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (f *secureRuntimeLabFixture) handleActionRunnerSelfRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
|
@ -748,6 +811,49 @@ func TestSecureRuntimeSourceManifestCoversTransitiveProviders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSecureRuntimeFixturePromotesPendingRunner(t *testing.T) {
|
||||
fixture := newSecureRuntimeLabFixture(nil, nil, nil, "fixture")
|
||||
defer fixture.actionServer.Shutdown()
|
||||
server := httptest.NewServer(fixture)
|
||||
defer server.Close()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
healthPath := filepath.Join(stateDir, "health.json")
|
||||
client := actionrunner.NewClient(actionrunner.TransportConfig{
|
||||
PulseURL: server.URL, APIToken: secureRuntimeRunnerSecretV1,
|
||||
StateDir: stateDir, HealthPath: healthPath, InsecureSkipVerify: true,
|
||||
ActivationNonce: strings.Repeat("a", 64),
|
||||
}, secureRuntimeLabAgentID, secureRuntimeLabHostname, "fixture")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- client.Run(ctx) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = client.Close()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("timed out stopping fixture action runner")
|
||||
}
|
||||
})
|
||||
|
||||
secureRuntimeWaitForActionRunner(t, fixture, true, 10*time.Second)
|
||||
admission, revoked, _ := fixture.actionSnapshot()
|
||||
if revoked || admission.ActivationPending || fixture.actionActivationCount() != 1 {
|
||||
t.Fatalf("fixture activation state = admission:%+v revoked:%t activations:%d", admission, revoked, fixture.actionActivationCount())
|
||||
}
|
||||
var health struct {
|
||||
Registered bool `json:"registered"`
|
||||
Activated bool `json:"activated"`
|
||||
}
|
||||
if err := json.Unmarshal(secureRuntimeReadFile(t, healthPath), &health); err != nil {
|
||||
t.Fatalf("decode fixture action-runner health: %v", err)
|
||||
}
|
||||
if !health.Registered || !health.Activated {
|
||||
t.Fatalf("fixture action-runner health = %+v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureRuntimeSystemdLab(t *testing.T) {
|
||||
if os.Getenv(secureRuntimeLabOptIn) != "1" {
|
||||
t.Skip("set PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 only inside a disposable systemd VM")
|
||||
|
|
@ -950,8 +1056,11 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
"--least-privilege", "--enable-privileged-helper", "--enable-action-runner")
|
||||
secureRuntimeWaitForActionRunner(t, fixture, true, 30*time.Second)
|
||||
secureRuntimeAssertActionRunnerInstalled(t)
|
||||
if activations := fixture.actionActivationCount(); activations != 1 {
|
||||
t.Fatalf("initial action-runner activation requests = %d, want 1", activations)
|
||||
}
|
||||
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRunner)+1, 20*time.Second)
|
||||
pass("separate_action_runner_install", "root action runner registered independently while the collector remained non-root and reporting", map[string]any{"runner_service_user": "root", "collector_service_user": "pulse-agent"})
|
||||
pass("separate_action_runner_install", "root action runner registered and activated independently while the collector remained non-root and reporting", map[string]any{"runner_service_user": "root", "collector_service_user": "pulse-agent", "fixture_activation_requests": 1})
|
||||
|
||||
actionContext, cancelAction := context.WithTimeout(agentexec.WithOrganizationID(context.Background(), secureRuntimeLabOrgID), 30*time.Second)
|
||||
defer cancelAction()
|
||||
|
|
@ -1017,7 +1126,10 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
|
|||
if revoked || rotatedAdmission.TokenID != secureRuntimeRunnerBindingV2 {
|
||||
t.Fatalf("rotated action-runner admission = %+v revoked=%t", rotatedAdmission, revoked)
|
||||
}
|
||||
pass("action_runner_credential_rotation", "mismatched invalidation was rejected, exact superseded session closed, replacement credential registered", map[string]any{"proof_scope": "in-memory-fixture", "superseded_session_invalidated": true, "replacement_registered": true})
|
||||
if activations := fixture.actionActivationCount(); activations != 2 {
|
||||
t.Fatalf("action-runner activation requests after rotation = %d, want 2", activations)
|
||||
}
|
||||
pass("action_runner_credential_rotation", "mismatched invalidation was rejected, exact superseded session closed, replacement credential registered and activated", map[string]any{"proof_scope": "in-memory-fixture", "superseded_session_invalidated": true, "replacement_registered": true, "fixture_activation_requests": 2})
|
||||
|
||||
reportsBeforeRemoval, _, _, _ := fixture.snapshot()
|
||||
uninstallOutput := secureRuntimeRunStandaloneInstaller(t, installerPath, "--uninstall-action-runner")
|
||||
|
|
|
|||
|
|
@ -73,7 +73,11 @@ SCENARIO_REQUIRED_OBSERVATIONS = {
|
|||
"automatic_failure_rollback": {"activation_committed": False, "restored_profile": "root-monitoring"},
|
||||
"ordinary_update_non_migration": {"collector_v2_installed": True, "selected_profile": "root-monitoring"},
|
||||
"final_safe_profile_apply": {"collector_service_user": "pulse-agent", "continuity_report_observed": True},
|
||||
"separate_action_runner_install": {"runner_service_user": "root", "collector_service_user": "pulse-agent"},
|
||||
"separate_action_runner_install": {
|
||||
"runner_service_user": "root",
|
||||
"collector_service_user": "pulse-agent",
|
||||
"fixture_activation_requests": 1,
|
||||
},
|
||||
"typed_action_receipt": {
|
||||
"action_receipt_kind": "pulse.host_storage_cleanup_result",
|
||||
"mutation_started": True,
|
||||
|
|
@ -85,6 +89,7 @@ SCENARIO_REQUIRED_OBSERVATIONS = {
|
|||
"proof_scope": "in-memory-fixture",
|
||||
"superseded_session_invalidated": True,
|
||||
"replacement_registered": True,
|
||||
"fixture_activation_requests": 2,
|
||||
},
|
||||
"action_runner_self_revoke": {"revocation_count": 1, "collector_continuity": True},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue