Skip to content

Commit 5df1cf7

Browse files
committed
feat: auto-provision Semaphore user, project, and token on reconcile
1 parent 6099385 commit 5df1cf7

2 files changed

Lines changed: 222 additions & 10 deletions

File tree

internal/controller/hermesinstance_controller.go

Lines changed: 217 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

internal/resources/semaphore.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ import (
66
hermesv1 "github.com/paperclipinc/hermes-operator/api/v1"
77
)
88

9-
// semaphoreEnabled reports whether the user opted into Semaphore integration.
10-
func semaphoreEnabled(inst *hermesv1.HermesInstance) bool {
9+
// SemaphoreEnabled reports whether the user opted into Semaphore integration.
10+
func SemaphoreEnabled(inst *hermesv1.HermesInstance) bool {
1111
return BoolValue(inst.Spec.Semaphore.Enabled)
1212
}
1313

1414
// BuildSemaphoreEnv returns env vars injected into the hermes container
1515
// when Semaphore integration is enabled. It also returns the semaphore-ui
1616
// skill entry for auto-installation.
1717
func BuildSemaphoreEnv(inst *hermesv1.HermesInstance) []corev1.EnvVar {
18-
if !semaphoreEnabled(inst) {
18+
if !SemaphoreEnabled(inst) {
1919
return nil
2020
}
2121
s := inst.Spec.Semaphore
@@ -40,7 +40,7 @@ func BuildSemaphoreEnv(inst *hermesv1.HermesInstance) []corev1.EnvVar {
4040
// BuildSemaphoreSkill returns the semaphore-ui skill entry for the
4141
// HermesInstance Skills list when Semaphore integration is enabled.
4242
func BuildSemaphoreSkill(inst *hermesv1.HermesInstance) *hermesv1.InstanceSkill {
43-
if !semaphoreEnabled(inst) {
43+
if !SemaphoreEnabled(inst) {
4444
return nil
4545
}
4646
return &hermesv1.InstanceSkill{
@@ -51,5 +51,5 @@ func BuildSemaphoreSkill(inst *hermesv1.HermesInstance) *hermesv1.InstanceSkill
5151
// SemaphoreCleanupOnDelete reports whether the operator should clean up
5252
// the Semaphore project and user when the HermesInstance is deleted.
5353
func SemaphoreCleanupOnDelete(inst *hermesv1.HermesInstance) bool {
54-
return semaphoreEnabled(inst) && BoolValue(inst.Spec.Semaphore.CleanupOnDelete)
54+
return SemaphoreEnabled(inst) && BoolValue(inst.Spec.Semaphore.CleanupOnDelete)
5555
}

0 commit comments

Comments
 (0)