@@ -692,11 +692,223 @@ func (r *HermesInstanceReconciler) reconcileTailscale(_ context.Context, _ *herm
692692 return nil
693693}
694694
695- // reconcileSemaphore is a no-op resource step: the SEMAPHORE_URL and
696- // SEMAPHORE_TOKEN env vars are injected by the StatefulSet builder,
697- // reconciled earlier. This step exists so the SemaphoreReady condition
698- // appears alongside the other per-subsystem conditions.
699- func (r * HermesInstanceReconciler ) reconcileSemaphore (_ context.Context , _ * hermesv1.HermesInstance ) error {
695+ // reconcileSemaphore provisions the Semaphore user, project, and API token
696+ // when spec.semaphore.enabled is true. It also ensures the token secret is
697+ // populated so the StatefulSet builder injects SEMAPHORE_TOKEN.
698+ func (r * HermesInstanceReconciler ) reconcileSemaphore (ctx context.Context , inst * hermesv1.HermesInstance ) error {
699+ if ! resources .SemaphoreEnabled (inst ) {
700+ return nil
701+ }
702+ s := inst .Spec .Semaphore
703+ if s .AdminTokenSecretRef == nil || s .TokenSecretRef == nil {
704+ return fmt .Errorf ("semaphore enabled but adminTokenSecretRef or tokenSecretRef not set" )
705+ }
706+
707+ // Read admin password from semaphore namespace
708+ var adminSecret corev1.Secret
709+ if err := r .Get (ctx , types.NamespacedName {
710+ Name : s .AdminTokenSecretRef .LocalObjectReference .Name , Namespace : "semaphore" ,
711+ }, & adminSecret ); err != nil {
712+ return fmt .Errorf ("read admin secret: %w" , err )
713+ }
714+ adminPassword := strings .TrimSpace (string (adminSecret .Data [s .AdminTokenSecretRef .Key ]))
715+ if adminPassword == "" {
716+ return fmt .Errorf ("admin password empty in secret %s" , s .AdminTokenSecretRef .LocalObjectReference .Name )
717+ }
718+
719+ // Login to Semaphore
720+ loginBody := fmt .Sprintf (`{"auth":"admin","password":"%s"}` , adminPassword )
721+ loginReq , _ := http .NewRequestWithContext (ctx , "POST" , s .URL + "/api/auth/login" ,
722+ strings .NewReader (loginBody ))
723+ loginReq .Header .Set ("Content-Type" , "application/json" )
724+ resp , err := http .DefaultClient .Do (loginReq )
725+ if err != nil {
726+ return fmt .Errorf ("semaphore login: %w" , err )
727+ }
728+ resp .Body .Close ()
729+ var cookie string
730+ for _ , c := range resp .Cookies () {
731+ if c .Name == "semaphore" {
732+ cookie = c .Value
733+ break
734+ }
735+ }
736+ if cookie == "" {
737+ return fmt .Errorf ("no semaphore session cookie" )
738+ }
739+
740+ type apiError struct { Error string `json:"error"` }
741+
742+ apiGet := func (path string , target interface {}) error {
743+ req , _ := http .NewRequestWithContext (ctx , "GET" , s .URL + path , nil )
744+ req .Header .Set ("Cookie" , "semaphore=" + cookie )
745+ r , err := http .DefaultClient .Do (req )
746+ if err != nil {
747+ return err
748+ }
749+ defer r .Body .Close ()
750+ return json .NewDecoder (r .Body ).Decode (target )
751+ }
752+
753+ apiPost := func (path , body string , target interface {}) error {
754+ req , _ := http .NewRequestWithContext (ctx , "POST" , s .URL + path , strings .NewReader (body ))
755+ req .Header .Set ("Content-Type" , "application/json" )
756+ req .Header .Set ("Cookie" , "semaphore=" + cookie )
757+ r , err := http .DefaultClient .Do (req )
758+ if err != nil {
759+ return err
760+ }
761+ defer r .Body .Close ()
762+ if r .StatusCode >= 400 {
763+ var ae apiError
764+ json .NewDecoder (r .Body ).Decode (& ae )
765+ return fmt .Errorf ("POST %s: %d %s" , path , r .StatusCode , ae .Error )
766+ }
767+ if target != nil {
768+ return json .NewDecoder (r .Body ).Decode (target )
769+ }
770+ return nil
771+ }
772+
773+ projectName := inst .Name
774+ agentUsername := inst .Name + "-agent"
775+ logger := log .FromContext (ctx )
776+
777+ // Ensure project exists
778+ var projects []struct {
779+ ID int `json:"id"`
780+ Name string `json:"name"`
781+ }
782+ if err := apiGet ("/api/projects" , & projects ); err != nil {
783+ return fmt .Errorf ("list projects: %w" , err )
784+ }
785+ var projectID int
786+ for _ , p := range projects {
787+ if p .Name == projectName {
788+ projectID = p .ID
789+ break
790+ }
791+ }
792+ if projectID == 0 {
793+ var created struct { ID int `json:"id"` }
794+ if err := apiPost ("/api/projects" ,
795+ fmt .Sprintf (`{"name":"%s","alert":false,"max_parallel_tasks":0}` , projectName ),
796+ & created ); err != nil {
797+ return fmt .Errorf ("create project %s: %w" , projectName , err )
798+ }
799+ projectID = created .ID
800+ logger .Info ("semaphore: created project" , "name" , projectName , "id" , projectID )
801+ }
802+
803+ // Ensure agent user exists
804+ var users []struct {
805+ ID int `json:"id"`
806+ Username string `json:"username"`
807+ }
808+ if err := apiGet ("/api/users" , & users ); err != nil {
809+ return fmt .Errorf ("list users: %w" , err )
810+ }
811+ var userID int
812+ for _ , u := range users {
813+ if u .Username == agentUsername {
814+ userID = u .ID
815+ break
816+ }
817+ }
818+ if userID == 0 {
819+ var created struct { ID int `json:"id"` }
820+ if err := apiPost ("/api/users" ,
821+ fmt .Sprintf (`{"name":"%s Agent","username":"%s","email":"%s@semaphore.local","password":"%s","admin":false}` ,
822+ inst .Name , agentUsername , agentUsername , agentUsername + "-auto" ),
823+ & created ); err != nil {
824+ return fmt .Errorf ("create user %s: %w" , agentUsername , err )
825+ }
826+ userID = created .ID
827+ logger .Info ("semaphore: created user" , "username" , agentUsername , "id" , userID )
828+ }
829+
830+ // Ensure user is in project as manager
831+ var projUsers []struct {
832+ ID int `json:"id"`
833+ Username string `json:"username"`
834+ Role string `json:"role"`
835+ }
836+ _ = apiGet (fmt .Sprintf ("/api/project/%d/users" , projectID ), & projUsers )
837+ var inProject bool
838+ for _ , pu := range projUsers {
839+ if pu .ID == userID {
840+ inProject = true
841+ break
842+ }
843+ }
844+ if ! inProject {
845+ if err := apiPost (fmt .Sprintf ("/api/project/%d/users" , projectID ),
846+ fmt .Sprintf (`{"user_id":%d,"role":"manager"}` , userID ), nil ); err != nil {
847+ return fmt .Errorf ("add user %d to project %d: %w" , userID , projectID , err )
848+ }
849+ logger .Info ("semaphore: added user to project" , "user" , agentUsername , "project" , projectName )
850+ }
851+
852+ // Ensure token secret exists with a valid API token
853+ var tokenSecret corev1.Secret
854+ err = r .Get (ctx , types.NamespacedName {
855+ Name : s .TokenSecretRef .LocalObjectReference .Name , Namespace : inst .Namespace ,
856+ }, & tokenSecret )
857+ if err != nil {
858+ if ! apierrors .IsNotFound (err ) {
859+ return fmt .Errorf ("read token secret: %w" , err )
860+ }
861+ // Secret doesn't exist — login as the user and generate a token
862+ userLoginBody := fmt .Sprintf (`{"auth":"%s","password":"%s"}` , agentUsername , agentUsername + "-auto" )
863+ userLoginReq , _ := http .NewRequestWithContext (ctx , "POST" , s .URL + "/api/auth/login" ,
864+ strings .NewReader (userLoginBody ))
865+ userLoginReq .Header .Set ("Content-Type" , "application/json" )
866+ userResp , err := http .DefaultClient .Do (userLoginReq )
867+ if err != nil {
868+ return fmt .Errorf ("user login: %w" , err )
869+ }
870+ userResp .Body .Close ()
871+ var userCookie string
872+ for _ , c := range userResp .Cookies () {
873+ if c .Name == "semaphore" {
874+ userCookie = c .Value
875+ break
876+ }
877+ }
878+ if userCookie == "" {
879+ return fmt .Errorf ("no session cookie for user %s" , agentUsername )
880+ }
881+
882+ // Generate API token
883+ tokenReq , _ := http .NewRequestWithContext (ctx , "POST" , s .URL + "/api/user/tokens" , strings .NewReader ("{}" ))
884+ tokenReq .Header .Set ("Content-Type" , "application/json" )
885+ tokenReq .Header .Set ("Cookie" , "semaphore=" + userCookie )
886+ tokenResp , err := http .DefaultClient .Do (tokenReq )
887+ if err != nil {
888+ return fmt .Errorf ("generate token: %w" , err )
889+ }
890+ defer tokenResp .Body .Close ()
891+ var tok struct { ID string `json:"id"` }
892+ if err := json .NewDecoder (tokenResp .Body ).Decode (& tok ); err != nil {
893+ return fmt .Errorf ("decode token: %w" , err )
894+ }
895+
896+ // Create the K8s secret
897+ secret := & corev1.Secret {
898+ ObjectMeta : metav1.ObjectMeta {
899+ Name : s .TokenSecretRef .LocalObjectReference .Name ,
900+ Namespace : inst .Namespace ,
901+ },
902+ StringData : map [string ]string {
903+ s .TokenSecretRef .Key : tok .ID ,
904+ },
905+ }
906+ if err := r .Create (ctx , secret ); err != nil {
907+ return fmt .Errorf ("create token secret: %w" , err )
908+ }
909+ logger .Info ("semaphore: created token secret" , "secret" , s .TokenSecretRef .LocalObjectReference .Name )
910+ }
911+
700912 return nil
701913}
702914
0 commit comments