Files
kandev/apps/backend/internal/gitlab/service_config.go
T
2026-08-05 18:22:31 +01:00

787 lines
25 KiB
Go

package gitlab
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"go.uber.org/zap"
)
// WorkspaceClientFactory constructs a client from one workspace's resolved
// config and credential. Tests replace it to avoid external GitLab calls.
type WorkspaceClientFactory func(context.Context, *GitLabConfig, string) (Client, error)
var ErrWorkspaceRequired = errors.New("workspace_id required")
var ErrInvalidConfig = errors.New("gitlab: invalid configuration")
var ErrNotConfigured = errors.New("gitlab: workspace not configured")
var ErrWorkspaceHostMismatch = errors.New("gitlab: workspace host mismatch")
const (
envGitLabHost = "GITLAB_HOST"
envKandevGitLabHost = "KANDEV_GITLAB_HOST"
)
// ResolveGitLabExecutionCredentials returns credentials for exactly one
// configured workspace. It is consumed by the orchestrator executor and never
// falls back to another workspace's connection.
func (s *Service) ResolveGitLabExecutionCredentials(ctx context.Context, workspaceID string) (string, string, error) {
cfg, err := s.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return "", "", err
}
if cfg == nil {
return "", "", ErrNotConfigured
}
if err := s.validateEnvironmentTokenHost(cfg.AuthMethod, cfg.Host); err != nil {
return "", "", err
}
if cfg.AuthMethod == AuthMethodGLab {
s.mu.RLock()
resolve := s.glabTokenFn
s.mu.RUnlock()
if resolve == nil {
return "", "", fmt.Errorf("%w: glab credential resolver unavailable", ErrInvalidConfig)
}
token, resolveErr := resolve(ctx, stripScheme(cfg.Host))
if resolveErr != nil || strings.TrimSpace(token) == "" {
if resolveErr == nil {
resolveErr = errors.New("empty token")
}
return "", "", fmt.Errorf("resolve glab token for %s: %w", cfg.Host, resolveErr)
}
return cfg.Host, strings.TrimSpace(token), nil
}
token, err := s.resolveWorkspaceToken(ctx, workspaceID, cfg.AuthMethod, "")
if err != nil {
return "", "", err
}
return cfg.Host, token, nil
}
// GetConfigForWorkspace returns public connection metadata for one workspace.
func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*GitLabConfig, error) {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return nil, ErrWorkspaceRequired
}
s.mu.RLock()
store := s.store
secrets := s.workspaceSecrets
s.mu.RUnlock()
if store == nil {
return nil, errors.New("gitlab store not configured")
}
cfg, err := store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil || cfg == nil || secrets == nil {
return cfg, err
}
cfg.HasSecret, err = secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID))
return cfg, err
}
// SetConfigForWorkspace validates credentials before replacing the saved
// connection, so a failed probe leaves the previous row and secret untouched.
func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*GitLabConfig, error) {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return nil, ErrWorkspaceRequired
}
cfg, token, err := s.resolveConfigRequest(ctx, workspaceID, req)
if err != nil {
return nil, err
}
client, err := s.buildWorkspaceClient(ctx, cfg, token)
if err != nil {
return nil, err
}
username, err := client.GetAuthenticatedUser(ctx)
if err != nil || username == "" {
if err == nil {
err = errors.New("GitLab returned an empty username")
}
return nil, fmt.Errorf("test GitLab connection: %w", err)
}
cfg.Username = username
if err := s.persistWorkspaceConfig(ctx, workspaceID, cfg, token); err != nil {
return nil, err
}
return s.GetConfigForWorkspace(ctx, workspaceID)
}
// TestConfigForWorkspace probes supplied or stored settings without persisting.
func (s *Service) TestConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) *TestConnectionResult {
cfg, token, err := s.resolveConfigRequest(ctx, strings.TrimSpace(workspaceID), req)
if err != nil {
return &TestConnectionResult{Error: publicConfigError(err)}
}
client, err := s.buildWorkspaceClient(ctx, cfg, token)
if err != nil {
return &TestConnectionResult{Error: "GitLab connection test failed"}
}
username, err := client.GetAuthenticatedUser(ctx)
if err != nil {
return &TestConnectionResult{Error: "GitLab connection test failed"}
}
return &TestConnectionResult{OK: username != "", Username: username}
}
func publicConfigError(err error) string {
switch {
case errors.Is(err, ErrWorkspaceRequired):
return "workspace_id required"
case errors.Is(err, ErrInvalidConfig):
return err.Error()
default:
return "GitLab connection test failed"
}
}
func (s *Service) resolveConfigRequest(ctx context.Context, workspaceID string, req *SetConfigRequest) (*GitLabConfig, string, error) {
if workspaceID == "" {
return nil, "", ErrWorkspaceRequired
}
if req == nil {
return nil, "", fmt.Errorf("%w: request required", ErrInvalidConfig)
}
host := strings.TrimSpace(req.Host)
authMethod := strings.TrimSpace(req.AuthMethod)
host, authMethod, err := s.configRequestDefaults(ctx, workspaceID, host, authMethod)
if err != nil {
return nil, "", err
}
normalizedHost, err := normalizeHostOrigin(host)
if err != nil {
return nil, "", fmt.Errorf("%w: %s", ErrInvalidConfig, err)
}
if !validWorkspaceAuthMethod(authMethod) {
return nil, "", fmt.Errorf("%w: unsupported auth_method %q", ErrInvalidConfig, authMethod)
}
if err := s.validateEnvironmentTokenHost(authMethod, normalizedHost); err != nil {
return nil, "", err
}
token, err := s.resolveWorkspaceToken(ctx, workspaceID, authMethod, strings.TrimSpace(req.Token))
if err != nil {
return nil, "", err
}
return &GitLabConfig{WorkspaceID: workspaceID, Host: normalizedHost, AuthMethod: authMethod}, token, nil
}
func (s *Service) configRequestDefaults(ctx context.Context, workspaceID, host, authMethod string) (string, string, error) {
if host != "" && authMethod != "" {
return host, authMethod, nil
}
stored, err := s.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return "", "", err
}
if stored == nil {
return host, authMethod, nil
}
if host == "" {
host = stored.Host
}
if authMethod == "" {
authMethod = stored.AuthMethod
}
return host, authMethod, nil
}
func normalizeHostOrigin(host string) (string, error) {
parsed, err := validateHost(strings.TrimSpace(host))
if err != nil {
return "", err
}
if parsed.Path != "" {
return "", errors.New("host must be an HTTP(S) origin without a path")
}
return parsed.String(), nil
}
func validWorkspaceAuthMethod(method string) bool {
switch method {
case AuthMethodPAT, AuthMethodGLab, AuthMethodEnvironment:
return true
default:
return false
}
}
func trustedEnvironmentTokenHost(startupHost string) string {
host := strings.TrimSpace(os.Getenv(envKandevGitLabHost))
if host == "" {
host = strings.TrimSpace(os.Getenv(envGitLabHost))
}
if host == "" {
host = startupHost
}
normalized, err := normalizeHostOrigin(host)
if err != nil {
return ""
}
return normalized
}
func (s *Service) validateEnvironmentTokenHost(authMethod, host string) error {
if authMethod != AuthMethodEnvironment {
return nil
}
s.mu.RLock()
trustedHost := s.environmentTokenHost
s.mu.RUnlock()
if trustedHost == "" || !strings.EqualFold(host, trustedHost) {
return fmt.Errorf("%w: environment credential host must match the trusted startup GitLab origin", ErrInvalidConfig)
}
return nil
}
func (s *Service) resolveWorkspaceToken(ctx context.Context, workspaceID, authMethod, supplied string) (string, error) {
if authMethod == AuthMethodEnvironment {
token := strings.TrimSpace(os.Getenv(secretNameToken))
if token == "" {
return "", fmt.Errorf("%w: GITLAB_TOKEN is empty", ErrInvalidConfig)
}
return token, nil
}
if authMethod != AuthMethodPAT {
return "", nil
}
if supplied != "" {
return supplied, nil
}
s.mu.RLock()
secrets := s.workspaceSecrets
s.mu.RUnlock()
if secrets == nil {
return "", fmt.Errorf("%w: token required", ErrInvalidConfig)
}
token, err := secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID))
if err != nil || strings.TrimSpace(token) == "" {
return "", fmt.Errorf("%w: token required", ErrInvalidConfig)
}
return strings.TrimSpace(token), nil
}
func (s *Service) persistWorkspaceConfig(ctx context.Context, workspaceID string, cfg *GitLabConfig, token string) error {
s.configMutationMu.Lock()
defer s.configMutationMu.Unlock()
s.mu.RLock()
store := s.store
secrets := s.workspaceSecrets
s.mu.RUnlock()
return s.persistWorkspaceConfigLocked(ctx, store, secrets, workspaceID, cfg, token)
}
func (s *Service) persistWorkspaceConfigLocked(ctx context.Context, store *Store, secrets WorkspaceSecretStore, workspaceID string, cfg *GitLabConfig, token string) error {
if store == nil {
return errors.New("gitlab store not configured")
}
snapshot, err := snapshotWorkspaceConnection(ctx, store, secrets, workspaceID)
if err != nil {
return fmt.Errorf("snapshot GitLab config: %w", err)
}
if err := persistWorkspaceSecret(ctx, secrets, workspaceID, cfg.AuthMethod, token, snapshot.secret); err != nil {
return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("store GitLab token: %w", err))
}
if err := store.SaveConfigForWorkspace(ctx, workspaceID, cfg); err != nil {
return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("store GitLab config: %w", err))
}
s.invalidateWorkspaceClient(workspaceID)
return nil
}
type workspaceSecretSnapshot struct {
exists bool
value string
}
type workspaceConnectionSnapshot struct {
config *GitLabConfig
secret workspaceSecretSnapshot
}
func snapshotWorkspaceConnection(ctx context.Context, store *Store, secrets WorkspaceSecretStore, workspaceID string) (workspaceConnectionSnapshot, error) {
cfg, err := store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return workspaceConnectionSnapshot{}, err
}
secret, err := snapshotWorkspaceSecret(ctx, secrets, SecretKeyForWorkspace(workspaceID))
if err != nil {
return workspaceConnectionSnapshot{}, err
}
return workspaceConnectionSnapshot{config: cfg, secret: secret}, nil
}
func snapshotWorkspaceSecret(ctx context.Context, secrets WorkspaceSecretStore, key string) (workspaceSecretSnapshot, error) {
if secrets == nil {
return workspaceSecretSnapshot{}, nil
}
exists, err := secrets.Exists(ctx, key)
if err != nil || !exists {
return workspaceSecretSnapshot{}, err
}
value, err := secrets.Reveal(ctx, key)
if err != nil {
return workspaceSecretSnapshot{}, err
}
return workspaceSecretSnapshot{exists: true, value: value}, nil
}
func persistWorkspaceSecret(ctx context.Context, secrets WorkspaceSecretStore, workspaceID, authMethod, token string, previous workspaceSecretSnapshot) error {
if authMethod == AuthMethodPAT {
if secrets == nil {
return errors.New("gitlab secret store not configured")
}
return secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), "GitLab token", token)
}
if secrets != nil && previous.exists {
return secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID))
}
return nil
}
func rollbackWorkspaceConnection(ctx context.Context, store *Store, secrets WorkspaceSecretStore, workspaceID string, snapshot workspaceConnectionSnapshot, cause error) error {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
var rollbackErrs []error
current, err := store.GetConfigForWorkspace(rollbackCtx, workspaceID)
if err != nil {
rollbackErrs = append(rollbackErrs, fmt.Errorf("read GitLab config during rollback: %w", err))
} else if !sameGitLabConfig(current, snapshot.config) {
if err := store.RestoreConfigForWorkspace(rollbackCtx, workspaceID, snapshot.config); err != nil {
rollbackErrs = append(rollbackErrs, fmt.Errorf("restore GitLab config: %w", err))
}
}
if err := restoreWorkspaceSecret(rollbackCtx, secrets, SecretKeyForWorkspace(workspaceID), snapshot.secret); err != nil {
rollbackErrs = append(rollbackErrs, fmt.Errorf("restore GitLab token: %w", err))
}
if len(rollbackErrs) == 0 {
return cause
}
return errors.Join(append([]error{cause}, rollbackErrs...)...)
}
func restoreWorkspaceSecret(ctx context.Context, secrets WorkspaceSecretStore, key string, snapshot workspaceSecretSnapshot) error {
if secrets == nil {
return nil
}
if snapshot.exists {
return secrets.Set(ctx, key, "GitLab token", snapshot.value)
}
exists, err := secrets.Exists(ctx, key)
if err != nil || !exists {
return err
}
return secrets.Delete(ctx, key)
}
func sameGitLabConfig(left, right *GitLabConfig) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return left.WorkspaceID == right.WorkspaceID &&
left.Host == right.Host &&
left.AuthMethod == right.AuthMethod &&
left.Username == right.Username &&
left.LastOK == right.LastOK &&
left.LastError == right.LastError &&
left.Revision == right.Revision &&
sameTimePointer(left.LastCheckedAt, right.LastCheckedAt) &&
left.CreatedAt.Equal(right.CreatedAt) &&
left.UpdatedAt.Equal(right.UpdatedAt)
}
func sameTimePointer(left, right *time.Time) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return left.Equal(*right)
}
func (s *Service) buildWorkspaceClient(ctx context.Context, cfg *GitLabConfig, token string) (Client, error) {
s.mu.RLock()
factory := s.workspaceClientFn
log := s.logger
s.mu.RUnlock()
if factory != nil {
return factory(ctx, cfg, token)
}
if os.Getenv("KANDEV_MOCK_GITLAB") == "true" {
return NewMockClient(cfg.Host), nil
}
switch cfg.AuthMethod {
case AuthMethodPAT, AuthMethodEnvironment:
return NewPATClient(cfg.Host, token), nil
case AuthMethodGLab:
client, err := NewGLabClient(ctx, cfg.Host)
if err != nil && log != nil {
log.Debug("glab CLI unavailable for workspace connection")
}
return client, err
default:
return nil, fmt.Errorf("%w: unsupported auth_method %q", ErrInvalidConfig, cfg.AuthMethod)
}
}
// ClientForWorkspace resolves and caches only the requested workspace client.
func (s *Service) ClientForWorkspace(ctx context.Context, workspaceID string) (Client, error) {
return s.ClientForWorkspaceHost(ctx, workspaceID, "")
}
func (s *Service) ClientForWorkspaceHost(ctx context.Context, workspaceID, expectedHost string) (Client, error) {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return nil, ErrWorkspaceRequired
}
expectedHost = strings.TrimSpace(expectedHost)
if expectedHost != "" {
normalized, err := normalizeHostOrigin(expectedHost)
if err != nil {
return nil, ErrWorkspaceHostMismatch
}
expectedHost = normalized
}
for {
client, retry, err := s.resolveWorkspaceClientRevision(ctx, workspaceID, expectedHost)
if err != nil || !retry {
return client, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
}
}
// RunWithWorkspaceClient resolves the configured host, credential, and client
// from one mutation-locked snapshot. Client construction, and the upstream
// action itself, run without holding the process-wide config mutation lock.
func (s *Service) RunWithWorkspaceClient(
ctx context.Context,
workspaceID, expectedHost string,
action func(Client) error,
) error {
client, err := s.ClientForWorkspaceHost(ctx, workspaceID, expectedHost)
if err != nil {
return err
}
return action(client)
}
func (s *Service) resolveWorkspaceClientRevision(
ctx context.Context, workspaceID, expectedHost string,
) (Client, bool, error) {
if expectedHost == "" {
s.mu.RLock()
cached := s.workspaceClients[workspaceID]
revision := s.workspaceClientRevs[workspaceID]
s.mu.RUnlock()
if cached != nil && revision == 0 {
return cached, false, nil
}
}
snapshot, err := s.snapshotWorkspaceClientRevision(ctx, workspaceID)
if err != nil {
return nil, false, err
}
if snapshot.config == nil {
return nil, false, ErrNotConfigured
}
if expectedHost != "" && !strings.EqualFold(snapshot.config.Host, expectedHost) {
return nil, false, ErrWorkspaceHostMismatch
}
s.mu.RLock()
cached := s.workspaceClients[workspaceID]
cachedRevision := s.workspaceClientRevs[workspaceID]
s.mu.RUnlock()
if cached != nil && cachedRevision == snapshot.config.Revision &&
strings.EqualFold(cached.Host(), snapshot.config.Host) {
return cached, false, nil
}
token, err := s.tokenForWorkspaceSnapshot(snapshot)
if err != nil {
return nil, false, err
}
client, err := s.buildWorkspaceClient(ctx, snapshot.config, token)
if err != nil {
return nil, false, err
}
published, current, err := s.publishWorkspaceClientRevision(ctx, workspaceID, snapshot.config, client)
if err != nil {
return nil, false, err
}
return published, !current, nil
}
func (s *Service) snapshotWorkspaceClientRevision(
ctx context.Context, workspaceID string,
) (workspaceConnectionSnapshot, error) {
s.configMutationMu.Lock()
defer s.configMutationMu.Unlock()
s.mu.RLock()
store := s.store
secrets := s.workspaceSecrets
s.mu.RUnlock()
if store == nil {
return workspaceConnectionSnapshot{}, errors.New("gitlab store not configured")
}
return snapshotWorkspaceConnection(ctx, store, secrets, workspaceID)
}
func (s *Service) publishWorkspaceClientRevision(
ctx context.Context,
workspaceID string,
snapshot *GitLabConfig,
client Client,
) (Client, bool, error) {
s.configMutationMu.Lock()
defer s.configMutationMu.Unlock()
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
return nil, false, errors.New("gitlab store not configured")
}
current, err := store.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return nil, false, err
}
if current == nil || current.Revision != snapshot.Revision ||
!strings.EqualFold(current.Host, snapshot.Host) {
return nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.workspaceClients == nil {
s.workspaceClients = make(map[string]Client)
}
if s.workspaceClientRevs == nil {
s.workspaceClientRevs = make(map[string]int64)
}
if existing := s.workspaceClients[workspaceID]; existing != nil &&
s.workspaceClientRevs[workspaceID] == snapshot.Revision {
return existing, true, nil
}
s.workspaceClients[workspaceID] = client
s.workspaceClientRevs[workspaceID] = snapshot.Revision
return client, true, nil
}
// GetStatusForWorkspace probes and reports only the selected connection.
func (s *Service) GetStatusForWorkspace(ctx context.Context, workspaceID string) (*Status, error) {
cfg, err := s.GetConfigForWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
if cfg == nil {
return &Status{Host: DefaultHost, AuthMethod: AuthMethodNone, RequiredScopes: []string{"api", "read_user"}}, nil
}
status := &Status{
Username: cfg.Username,
AuthMethod: cfg.AuthMethod,
Host: cfg.Host,
TokenConfigured: cfg.HasSecret,
RequiredScopes: []string{"api", "read_user"},
}
client, clientErr := s.ClientForWorkspace(ctx, workspaceID)
if clientErr != nil {
status.ConnectionError = connectionUnavailable
return status, nil
}
status.Authenticated, clientErr = client.IsAuthenticated(ctx)
if clientErr != nil {
status.ConnectionError = connectionUnavailable
}
if status.Authenticated {
status.Username, _ = client.GetAuthenticatedUser(ctx)
}
if glab, ok := client.(*GLabClient); ok {
status.GLabVersion = glab.Version()
}
return status, nil
}
func (s *Service) invalidateWorkspaceClient(workspaceID string) {
s.mu.Lock()
delete(s.workspaceClients, workspaceID)
delete(s.workspaceClientRevs, workspaceID)
s.mu.Unlock()
}
func (s *Service) clientForTask(ctx context.Context, taskID string) (Client, error) {
s.mu.RLock()
store := s.store
legacyOnly := s.workspaceSecrets == nil
s.mu.RUnlock()
if store == nil {
return s.Client(), nil
}
if legacyOnly {
return s.Client(), nil
}
workspaceID, err := store.WorkspaceIDForTask(ctx, taskID)
if err != nil {
return nil, err
}
return s.ClientForWorkspace(ctx, workspaceID)
}
// ErrWorkspaceClientRequired marks every failure path of clientForTaskStrict.
// Unlike clientForTask, this helper never falls back to the ambient/legacy
// Service.Client() singleton — that fallback resolved reviewer identity and
// MR fetches against the wrong GitLab account twice during PR #2038's
// review (commits c7116a228, 8a8d3f1e1), and TaskMR has no workspace column
// of its own, making GitLab structurally more exposed to the same class of
// bug than GitHub was. Every MR lifecycle-automation call site must resolve
// its client through this helper, not clientForTask.
var ErrWorkspaceClientRequired = errors.New("gitlab: workspace-scoped client required")
func (s *Service) clientForTaskStrict(ctx context.Context, taskID string) (Client, error) {
s.mu.RLock()
store := s.store
secretsUnavailable := s.workspaceSecrets == nil
s.mu.RUnlock()
if store == nil {
return nil, fmt.Errorf("%w: gitlab store not configured", ErrWorkspaceClientRequired)
}
if secretsUnavailable {
return nil, fmt.Errorf("%w: workspace secret store not configured", ErrWorkspaceClientRequired)
}
workspaceID, err := store.WorkspaceIDForTask(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("%w: resolve task workspace: %w", ErrWorkspaceClientRequired, err)
}
if strings.TrimSpace(workspaceID) == "" {
return nil, fmt.Errorf("%w: workspace_id is required", ErrWorkspaceClientRequired)
}
return s.ClientForWorkspace(ctx, workspaceID)
}
// DeleteConfigForWorkspace deletes one workspace's metadata and PAT.
func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return ErrWorkspaceRequired
}
s.configMutationMu.Lock()
defer s.configMutationMu.Unlock()
s.mu.RLock()
store := s.store
secrets := s.workspaceSecrets
s.mu.RUnlock()
if store == nil {
return errors.New("gitlab store not configured")
}
snapshot, err := snapshotWorkspaceConnection(ctx, store, secrets, workspaceID)
if err != nil {
return fmt.Errorf("snapshot GitLab config: %w", err)
}
if secrets != nil && snapshot.secret.exists {
if err := secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil {
return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("delete GitLab token: %w", err))
}
}
if err := store.DeleteConfigForWorkspace(ctx, workspaceID); err != nil {
return rollbackWorkspaceConnection(ctx, store, secrets, workspaceID, snapshot, fmt.Errorf("delete GitLab config: %w", err))
}
s.invalidateWorkspaceClient(workspaceID)
return nil
}
// RecordWorkspaceAuthHealth probes every configured workspace independently
// and persists only sanitized status. One broken connection never prevents
// later workspace rows from being checked.
func (s *Service) RecordWorkspaceAuthHealth(ctx context.Context) {
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
return
}
workspaceIDs, err := store.ListConfigWorkspaceIDs(ctx)
if err != nil {
s.logger.Warn("gitlab health: list configured workspaces", zap.Error(err))
return
}
for _, workspaceID := range workspaceIDs {
if ctx.Err() != nil {
return
}
s.recordWorkspaceAuthHealth(ctx, store, workspaceID)
}
}
func (s *Service) recordWorkspaceAuthHealth(ctx context.Context, store *Store, workspaceID string) {
checkedAt := time.Now().UTC()
client, revision, err := s.clientForWorkspaceRevision(ctx, store, workspaceID)
if err != nil {
_ = s.updateConfigHealth(ctx, store, workspaceID, "", false, "connection unavailable", checkedAt, revision)
return
}
username, err := client.GetAuthenticatedUser(ctx)
if err != nil || strings.TrimSpace(username) == "" {
_ = s.updateConfigHealth(ctx, store, workspaceID, "", false, "authentication failed", checkedAt, revision)
return
}
if err := s.updateConfigHealth(ctx, store, workspaceID, username, true, "", checkedAt, revision); err != nil {
s.logger.Warn("gitlab health: persist result", zap.String("workspace_id", workspaceID), zap.Error(err))
}
}
func (s *Service) clientForWorkspaceRevision(ctx context.Context, store *Store, workspaceID string) (Client, int64, error) {
s.configMutationMu.Lock()
s.mu.RLock()
secrets := s.workspaceSecrets
s.mu.RUnlock()
snapshot, err := snapshotWorkspaceConnection(ctx, store, secrets, workspaceID)
s.configMutationMu.Unlock()
if err != nil {
return nil, 0, err
}
if snapshot.config == nil {
return nil, 0, ErrNotConfigured
}
token, err := s.tokenForWorkspaceSnapshot(snapshot)
if err != nil {
return nil, snapshot.config.Revision, err
}
client, err := s.buildWorkspaceClient(ctx, snapshot.config, token)
return client, snapshot.config.Revision, err
}
func (s *Service) tokenForWorkspaceSnapshot(snapshot workspaceConnectionSnapshot) (string, error) {
switch snapshot.config.AuthMethod {
case AuthMethodPAT:
if !snapshot.secret.exists || strings.TrimSpace(snapshot.secret.value) == "" {
return "", fmt.Errorf("%w: token required", ErrInvalidConfig)
}
return strings.TrimSpace(snapshot.secret.value), nil
case AuthMethodEnvironment:
if err := s.validateEnvironmentTokenHost(snapshot.config.AuthMethod, snapshot.config.Host); err != nil {
return "", err
}
token := strings.TrimSpace(os.Getenv(secretNameToken))
if token == "" {
return "", fmt.Errorf("%w: GITLAB_TOKEN is empty", ErrInvalidConfig)
}
return token, nil
default:
return "", nil
}
}
func (s *Service) updateConfigHealth(ctx context.Context, store *Store, workspaceID, username string, ok bool, errMsg string, checkedAt time.Time, revision int64) error {
if revision == 0 {
return nil
}
s.configMutationMu.Lock()
defer s.configMutationMu.Unlock()
_, err := store.UpdateConfigHealthForRevision(ctx, workspaceID, username, ok, errMsg, checkedAt, revision)
return err
}