@@ -22,6 +22,8 @@ import (
2222 "github.com/solutionforest/ephemeral-action-runner/internal/config"
2323 gh "github.com/solutionforest/ephemeral-action-runner/internal/github"
2424 "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust"
25+ "github.com/solutionforest/ephemeral-action-runner/internal/logging"
26+ poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state"
2527 "github.com/solutionforest/ephemeral-action-runner/internal/provider"
2628)
2729
@@ -396,8 +398,8 @@ func TestHostTrustReconciliationFencesWhenTransportVerificationFails(t *testing.
396398 if activator .calls != 0 {
397399 t .Fatalf ("activation calls = %d, want zero while fencing the unhealthy registered runner" , activator .calls )
398400 }
399- if activator .verifyCalls != 1 {
400- t .Fatalf ("runtime verification calls = %d, want one before fencing the unhealthy registered runner " , activator .verifyCalls )
401+ if activator .verifyCalls != 0 {
402+ t .Fatalf ("common runtime verification calls = %d, want zero when a dedicated host-trust verifier exists " , activator .verifyCalls )
401403 }
402404 if activator .verifyHostTrustCalls != 1 {
403405 t .Fatalf ("provider host-trust verification calls = %d, want one before fencing the unhealthy registered runner" , activator .verifyHostTrustCalls )
@@ -438,8 +440,8 @@ func TestHostTrustReconciliationDoesNotReactivateHealthyTransport(t *testing.T)
438440 if activator .calls != 0 {
439441 t .Fatalf ("host trust transport activations = %d, want zero for healthy current-generation transport" , activator .calls )
440442 }
441- if activator .verifyCalls != 1 {
442- t .Fatalf ("runtime verification calls = %d, want one for healthy current-generation transport " , activator .verifyCalls )
443+ if activator .verifyCalls != 0 {
444+ t .Fatalf ("common runtime verification calls = %d, want zero when a dedicated host-trust verifier exists " , activator .verifyCalls )
443445 }
444446 if activator .verifyHostTrustCalls != 1 {
445447 t .Fatalf ("provider host-trust verification calls = %d, want one for healthy current-generation transport" , activator .verifyHostTrustCalls )
@@ -478,8 +480,8 @@ func TestHostTrustReconciliationFencesBusyRunnerWhenTransportVerificationFails(t
478480 if activator .calls != 0 {
479481 t .Fatalf ("host trust transport activations = %d, want zero for failed verification" , activator .calls )
480482 }
481- if activator .verifyCalls != 1 {
482- t .Fatalf ("runtime verification calls = %d, want one before fencing the busy runner " , activator .verifyCalls )
483+ if activator .verifyCalls != 0 {
484+ t .Fatalf ("common runtime verification calls = %d, want zero when a dedicated host-trust verifier exists " , activator .verifyCalls )
483485 }
484486 if activator .verifyHostTrustCalls != 1 {
485487 t .Fatalf ("provider host-trust verification calls = %d, want one before fencing the busy runner" , activator .verifyHostTrustCalls )
@@ -492,6 +494,241 @@ func TestHostTrustReconciliationFencesBusyRunnerWhenTransportVerificationFails(t
492494 }
493495}
494496
497+ func TestHostTrustReconciliationBusyRunnerIgnoresJobMutableGeneralRuntimeState (t * testing.T ) {
498+ fake := & fakeProvider {instances : []provider.Instance {{Name : "runner-1" , ProviderID : "fake:runner-1" , State : "running" }}}
499+ activator := & hostTrustVerifyingLifecycle {
500+ activatingLifecycle : & activatingLifecycle {
501+ Lifecycle : provider .AdaptLegacy (fake , false ),
502+ verifyErr : errors .New ("workflow-created Docker client configuration is present" ),
503+ },
504+ }
505+ github := & fakeGitHub {runner : gh.Runner {Name : "runner-1" , ID : 42 , Status : "online" , Busy : true }, found : true }
506+ manager := Manager {
507+ Config : config.Config {
508+ Provider : config.ProviderConfig {Type : "docker-sandboxes" },
509+ Image : config.ImageConfig {HostTrustMode : config .HostTrustModeOverlay , HostTrustScopes : []string {"system" }},
510+ },
511+ Provider : fake ,
512+ Lifecycle : activator ,
513+ GitHub : github ,
514+ }
515+ var console bytes.Buffer
516+ logDirectory := t .TempDir ()
517+ manager .Config .Logging .Directory = logDirectory
518+ runtime , err := logging .NewRuntime (logging.Options {Directory : logDirectory , ManagerSinks : logging .SinkConsole , Stdout : & console , Stderr : & console })
519+ if err != nil {
520+ t .Fatal (err )
521+ }
522+ defer runtime .Close ()
523+ manager .Logging = runtime
524+ current := hosttrust.Snapshot {Generation : "g1" , HostOS : "windows" , Scopes : []string {"system" }, Certificates : []hosttrust.Certificate {{Name : "root.crt" , PEM : []byte ("pem" )}}, CollectedAt : time .Now ().UTC ()}
525+ active := map [string ]ProvisionedInstance {"runner-1" : {Name : "runner-1" , ProviderID : "fake:runner-1" , RunnerID : 42 , HostTrustGeneration : "g1" , ProviderOwned : true , Phase : LifecycleReady }}
526+ busyHandoff := make (map [string ]bool )
527+
528+ manager .reconcileHostTrustRunners (context .Background (), active , current , busyHandoff )
529+ manager .reconcileHostTrustRunners (context .Background (), active , current , busyHandoff )
530+
531+ if got := active ["runner-1" ].Phase ; got != LifecycleReady {
532+ t .Fatalf ("runner phase = %s, want %s" , got , LifecycleReady )
533+ }
534+ if activator .verifyCalls != 0 {
535+ t .Fatalf ("common runtime verification calls = %d, want zero for job-mutable runtime state" , activator .verifyCalls )
536+ }
537+ if activator .verifyHostTrustCalls != 2 {
538+ t .Fatalf ("provider host-trust verification calls = %d, want one read-only transport check per reconciliation" , activator .verifyHostTrustCalls )
539+ }
540+ if got := len (hostTrustLeaseInputs (fake )); got != 1 {
541+ t .Fatalf ("busy handoff lease writes = %d, want one bounded lease" , got )
542+ }
543+ if got := atomic .LoadInt32 (& github .deleteCalls ); got != 0 {
544+ t .Fatalf ("GitHub registration fence calls = %d, want zero for healthy busy transport" , got )
545+ }
546+ if output := console .String (); strings .Contains (output , "host trust transport verification warning" ) || strings .Contains (output , "host trust registration fencing warning" ) {
547+ t .Fatalf ("healthy busy reconciliation emitted transport or fencing warning: %q" , output )
548+ }
549+ }
550+
551+ func TestHostTrustReconciliationActivatorOnlyFallsBackToCommonRuntimeVerification (t * testing.T ) {
552+ fake := & fakeProvider {instances : []provider.Instance {{Name : "runner-1" , ProviderID : "fake:runner-1" , State : "running" }}}
553+ activator := & activatingLifecycle {
554+ Lifecycle : provider .AdaptLegacy (fake , false ),
555+ verifyErr : errors .New ("runtime unavailable" ),
556+ }
557+ github := & fakeGitHub {runner : gh.Runner {Name : "runner-1" , ID : 42 , Status : "online" }, found : true }
558+ manager := Manager {
559+ Config : config.Config {
560+ Image : config.ImageConfig {HostTrustMode : config .HostTrustModeOverlay , HostTrustScopes : []string {"system" }},
561+ },
562+ Provider : fake ,
563+ Lifecycle : activator ,
564+ GitHub : github ,
565+ }
566+ current := hosttrust.Snapshot {Generation : "g1" , HostOS : "linux" , Scopes : []string {"system" }, Certificates : []hosttrust.Certificate {{Name : "root.crt" , PEM : []byte ("pem" )}}, CollectedAt : time .Now ().UTC ()}
567+ active := map [string ]ProvisionedInstance {"runner-1" : {Name : "runner-1" , ProviderID : "fake:runner-1" , RunnerID : 42 , HostTrustGeneration : "g1" , ProviderOwned : true , Phase : LifecycleReady }}
568+
569+ manager .reconcileHostTrustRunners (context .Background (), active , current , make (map [string ]bool ))
570+
571+ if activator .verifyCalls != 1 {
572+ t .Fatalf ("common runtime verification calls = %d, want one fallback verification" , activator .verifyCalls )
573+ }
574+ if got := active ["runner-1" ].Phase ; got != LifecycleQuarantined {
575+ t .Fatalf ("runner phase = %s, want %s" , got , LifecycleQuarantined )
576+ }
577+ if got := atomic .LoadInt32 (& github .deleteCalls ); got != 1 {
578+ t .Fatalf ("GitHub registration fence calls = %d, want one after fallback verification failure" , got )
579+ }
580+ }
581+
582+ func TestDurableHostTrustQuarantineDoesNotRepeatBusyFenceAndRetiresWhenIdle (t * testing.T ) {
583+ manager , store , name := readyLifecycleManager (t )
584+ fake := & fakeProvider {instances : []provider.Instance {{Name : name , ProviderID : "docker:ready-id" , State : "running" }}}
585+ activator := & hostTrustVerifyingLifecycle {
586+ activatingLifecycle : & activatingLifecycle {Lifecycle : provider .AdaptLegacy (fake , false )},
587+ verifyHostTrustErr : errors .New ("relay marker unavailable" ),
588+ }
589+ busyRunner := gh.Runner {Name : name , ID : 42 , Status : "online" , Busy : true }
590+ github := & fakeGitHub {
591+ runner : busyRunner ,
592+ found : true ,
593+ listRunners : []gh.Runner {busyRunner },
594+ deleteErr : errors .New ("runner is currently running a job" ),
595+ }
596+ manager .Config .Image = config.ImageConfig {HostTrustMode : config .HostTrustModeOverlay , HostTrustScopes : []string {"system" }}
597+ manager .Provider = fake
598+ manager .Lifecycle = activator
599+ manager .GitHub = github
600+ current := hosttrust.Snapshot {Generation : "g1" , HostOS : "windows" , Scopes : []string {"system" }, Certificates : []hosttrust.Certificate {{Name : "root.crt" , PEM : []byte ("pem" )}}, CollectedAt : time .Now ().UTC ()}
601+ active := map [string ]ProvisionedInstance {name : {Name : name , ProviderID : "docker:ready-id" , RunnerID : 42 , HostTrustGeneration : "g1" , ProviderOwned : true , Phase : LifecycleReady }}
602+
603+ manager .reconcileHostTrustRunners (context .Background (), active , current , make (map [string ]bool ))
604+ if got := atomic .LoadInt32 (& github .deleteCalls ); got != 1 {
605+ t .Fatalf ("initial GitHub registration fence calls = %d, want 1" , got )
606+ }
607+ if got := active [name ].Phase ; got != LifecycleQuarantined {
608+ t .Fatalf ("phase after failed busy fence = %s, want %s" , got , LifecycleQuarantined )
609+ }
610+
611+ var err error
612+ active , err = manager .reconcilePhysicalPool (context .Background (), active , true )
613+ if err != nil {
614+ t .Fatal (err )
615+ }
616+ manager .reconcileHostTrustRunners (context .Background (), active , current , make (map [string ]bool ))
617+ if got := active [name ].Phase ; got != LifecycleQuarantined {
618+ t .Fatalf ("phase after busy reconciliation = %s, want durable quarantine" , got )
619+ }
620+ if got := atomic .LoadInt32 (& github .deleteCalls ); got != 1 {
621+ t .Fatalf ("GitHub registration fence calls after busy reconciliation = %d, want no repeat" , got )
622+ }
623+ if activator .verifyHostTrustCalls != 1 {
624+ t .Fatalf ("provider host-trust verification calls = %d, want no repeat after durable quarantine" , activator .verifyHostTrustCalls )
625+ }
626+ record , err := store .Read (context .Background (), name )
627+ if err != nil {
628+ t .Fatal (err )
629+ }
630+ if record .Phase != poolstate .PhaseQuarantined {
631+ t .Fatalf ("durable phase = %s, want %s" , record .Phase , poolstate .PhaseQuarantined )
632+ }
633+
634+ idleRunner := busyRunner
635+ idleRunner .Busy = false
636+ github .runner = idleRunner
637+ github .listRunners = []gh.Runner {idleRunner }
638+ github .deleteErr = nil
639+ github .deleteFunc = func (context.Context , int64 ) error {
640+ github .found = false
641+ github .listRunners = nil
642+ return nil
643+ }
644+ active , err = manager .reconcilePhysicalPool (context .Background (), active , true )
645+ if err != nil {
646+ t .Fatal (err )
647+ }
648+ if _ , found := active [name ]; found {
649+ t .Fatalf ("durably quarantined idle runner remains active: %#v" , active [name ])
650+ }
651+ if got := atomic .LoadInt32 (& github .deleteCalls ); got != 2 {
652+ t .Fatalf ("total GitHub deletion calls = %d, want initial busy fence plus idle cleanup" , got )
653+ }
654+ if got := atomic .LoadInt32 (& fake .deleteCalls ); got != 1 {
655+ t .Fatalf ("provider deletion calls = %d, want one exact idle cleanup" , got )
656+ }
657+ record , err = store .Read (context .Background (), name )
658+ if err != nil {
659+ t .Fatal (err )
660+ }
661+ if record .Phase != poolstate .PhaseTombstoned {
662+ t .Fatalf ("durable phase after idle cleanup = %s, want %s" , record .Phase , poolstate .PhaseTombstoned )
663+ }
664+ }
665+
666+ func TestReconciliationHydratesDurableBusyQuarantineWithoutReAdoption (t * testing.T ) {
667+ manager , store , name := readyLifecycleManager (t )
668+ if _ , err := store .Transition (context .Background (), name , poolstate.Transition {Action : poolstate .ActionQuarantine , Reason : "host trust transport unavailable" }); err != nil {
669+ t .Fatal (err )
670+ }
671+ fake := & fakeProvider {instances : []provider.Instance {{Name : name , ProviderID : "docker:ready-id" , State : "running" }}}
672+ busyRunner := gh.Runner {Name : name , ID : 42 , Status : "online" , Busy : true }
673+ github := & fakeGitHub {runner : busyRunner , found : true , listRunners : []gh.Runner {busyRunner }}
674+ manager .Provider = fake
675+ manager .Lifecycle = provider .AdaptLegacy (fake , false )
676+ manager .GitHub = github
677+
678+ active , err := manager .reconcilePhysicalPool (context .Background (), nil , true )
679+ if err != nil {
680+ t .Fatal (err )
681+ }
682+ if got := active [name ].Phase ; got != LifecycleQuarantined {
683+ t .Fatalf ("hydrated phase = %s, want %s" , got , LifecycleQuarantined )
684+ }
685+ if got := atomic .LoadInt32 (& github .deleteCalls ); got != 0 {
686+ t .Fatalf ("GitHub deletion calls = %d, want zero while hydrated quarantine is busy" , got )
687+ }
688+ if got := atomic .LoadInt32 (& fake .deleteCalls ); got != 0 {
689+ t .Fatalf ("provider deletion calls = %d, want zero while hydrated quarantine is busy" , got )
690+ }
691+ record , err := store .Read (context .Background (), name )
692+ if err != nil {
693+ t .Fatal (err )
694+ }
695+ if record .Phase != poolstate .PhaseQuarantined {
696+ t .Fatalf ("durable phase = %s, want %s" , record .Phase , poolstate .PhaseQuarantined )
697+ }
698+ }
699+
700+ func TestReconciliationRecoversTransientQuarantineWhenDurableStateIsReady (t * testing.T ) {
701+ manager , store , name := readyLifecycleManager (t )
702+ fake := & fakeProvider {instances : []provider.Instance {{Name : name , ProviderID : "docker:ready-id" , State : "running" }}}
703+ idleRunner := gh.Runner {Name : name , ID : 42 , Status : "online" }
704+ github := & fakeGitHub {runner : idleRunner , found : true , listRunners : []gh.Runner {idleRunner }}
705+ manager .Provider = fake
706+ manager .Lifecycle = provider .AdaptLegacy (fake , false )
707+ manager .GitHub = github
708+ known := map [string ]ProvisionedInstance {name : {Name : name , ProviderID : "docker:ready-id" , RunnerID : 42 , ProviderOwned : true , Phase : LifecycleQuarantined }}
709+
710+ active , err := manager .reconcilePhysicalPool (context .Background (), known , true )
711+ if err != nil {
712+ t .Fatal (err )
713+ }
714+ if got := active [name ].Phase ; got != LifecycleReady {
715+ t .Fatalf ("recovered phase = %s, want %s" , got , LifecycleReady )
716+ }
717+ if got := atomic .LoadInt32 (& github .deleteCalls ); got != 0 {
718+ t .Fatalf ("GitHub deletion calls = %d, want zero for transient quarantine recovery" , got )
719+ }
720+ if got := atomic .LoadInt32 (& fake .deleteCalls ); got != 0 {
721+ t .Fatalf ("provider deletion calls = %d, want zero for transient quarantine recovery" , got )
722+ }
723+ record , err := store .Read (context .Background (), name )
724+ if err != nil {
725+ t .Fatal (err )
726+ }
727+ if record .Phase != poolstate .PhaseReady {
728+ t .Fatalf ("durable phase = %s, want unchanged %s" , record .Phase , poolstate .PhaseReady )
729+ }
730+ }
731+
495732func TestHostTrustReconciliationFencesRegistrationWhenIdleLeaseRefreshFails (t * testing.T ) {
496733 fake := & fakeProvider {execErrs : []error {errors .New ("lease transport unavailable" ), nil }}
497734 github := & fakeGitHub {runner : gh.Runner {Name : "runner-1" , ID : 42 , Status : "online" }, found : true }
0 commit comments