daemon: golang WS hub, REST, gitlab, docker spawner, sqlite (18/18 tests)

This commit is contained in:
Raphael Westphal
2026-08-18 13:42:34 +02:00
parent 7d5f866527
commit c62900e613
17 changed files with 3100 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
package main
// gitlab.go — GitLab PAT management and project listing.
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
// Settings keys and GitLab defaults.
const (
settingGitLabToken string = "gitlab_pat"
projectsPerPage int = 50
gitlabTimeout time.Duration = 15 * time.Second
)
// GitLabError marks upstream GitLab failures (mapped to 502 by the API).
type GitLabError struct{ msg string }
func (e *GitLabError) Error() string { return e.msg }
var errNotConnected = errors.New("gitlab not connected")
// GitLabRepo is the /api/gitlab/repos item shape (protocol field names).
type GitLabRepo struct {
Path string `json:"path"`
Name string `json:"name"`
Namespace string `json:"namespace"`
LastActivityAt string `json:"lastActivityAt"`
WebURL string `json:"webUrl"`
DefaultBranch string `json:"defaultBranch"`
}
// gitlabProject is the subset of the upstream projects API we map from.
type gitlabProject struct {
PathWithNamespace string `json:"path_with_namespace"`
Name string `json:"name"`
Namespace struct {
Path string `json:"path"`
} `json:"namespace"`
LastActivityAt string `json:"last_activity_at"`
WebURL string `json:"web_url"`
DefaultBranch string `json:"default_branch"`
}
// GitLab stores/validates the PAT in the settings table and lists projects.
type GitLab struct {
store *Store
baseURL string
insecure bool
}
func NewGitLab(store *Store, baseURL string) *GitLab {
return &GitLab{store: store, baseURL: strings.TrimRight(baseURL, "/")}
}
// NewGitLabWithClient is the test seam: baseURL + transport overrides.
func NewGitLabWithClient(store *Store, baseURL string, insecureSkipVerify bool) *GitLab {
return &GitLab{store: store, baseURL: strings.TrimRight(baseURL, "/"), insecure: insecureSkipVerify}
}
func (g *GitLab) httpClient() *http.Client {
client := &http.Client{Timeout: gitlabTimeout}
if g.insecure {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test seam only
}
}
return client
}
// Status reports connection state; never includes the PAT.
func (g *GitLab) Status() map[string]any {
out := map[string]any{"connected": false, "baseUrl": g.baseURL}
token, ok, err := g.store.GetSetting(settingGitLabToken)
if err != nil || !ok || token == "" {
return out
}
out["connected"] = true
if username, ok, _ := g.store.GetSetting(settingGitLabUsername); ok {
out["username"] = username
}
return out
}
const settingGitLabUsername string = "gitlab_username"
// Connect validates the PAT against /api/v4/user and stores it.
func (g *GitLab) Connect(ctx context.Context, token string) (string, error) {
var user struct {
Username string `json:"username"`
}
if err := g.do(ctx, "/api/v4/user", token, &user); err != nil {
return "", err
}
if user.Username == "" {
return "", &GitLabError{msg: "gitlab returned no username for token"}
}
if err := g.store.SetSetting(settingGitLabToken, token); err != nil {
return "", err
}
if err := g.store.SetSetting(settingGitLabUsername, user.Username); err != nil {
return "", err
}
return user.Username, nil
}
// Disconnect drops the stored PAT.
func (g *GitLab) Disconnect() error {
if err := g.store.DeleteSetting(settingGitLabToken); err != nil {
return err
}
return g.store.DeleteSetting(settingGitLabUsername)
}
// token returns the stored PAT or errNotConnected.
func (g *GitLab) token() (string, error) {
token, ok, err := g.store.GetSetting(settingGitLabToken)
if err != nil {
return "", err
}
if !ok || token == "" {
return "", errNotConnected
}
return token, nil
}
// Repos lists member projects sorted by most recent activity.
func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
token, err := g.token()
if err != nil {
return nil, err
}
var projects []gitlabProject
path := fmt.Sprintf("/api/v4/projects?membership=true&order_by=last_activity_at&per_page=%d", projectsPerPage)
if err := g.do(ctx, path, token, &projects); err != nil {
return nil, err
}
repos := make([]GitLabRepo, 0, len(projects))
for _, p := range projects {
if p.PathWithNamespace == "" {
continue
}
repos = append(repos, GitLabRepo{
Path: p.PathWithNamespace,
Name: p.Name,
Namespace: p.Namespace.Path,
LastActivityAt: p.LastActivityAt,
WebURL: p.WebURL,
DefaultBranch: p.DefaultBranch,
})
}
sort.SliceStable(repos, func(i, j int) bool {
return repos[i].LastActivityAt > repos[j].LastActivityAt
})
return repos, nil
}
func (g *GitLab) do(ctx context.Context, path, token string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Private-Token", token)
resp, err := g.httpClient().Do(req)
if err != nil {
return &GitLabError{msg: fmt.Sprintf("gitlab request failed: %v", err)}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &GitLabError{msg: fmt.Sprintf("gitlab %s returned %d", path, resp.StatusCode)}
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return &GitLabError{msg: fmt.Sprintf("gitlab %s: decode: %v", path, err)}
}
return nil
}