From 49840225e2f34a91ceb9a236c42665d9eb0f036e Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 15:07:02 +0200 Subject: [PATCH] daemon: Gitea v1 API adapter (git.westphal.fr is Gitea, not GitLab); fix golang Dockerfile to 1.26; sudo docker in deploy --- daemon/Dockerfile | 2 +- daemon/api_extra_test.go | 12 ++++----- daemon/docker.go | 2 +- daemon/gitlab.go | 51 +++++++++++++++++++------------------ daemon/gitlab_extra_test.go | 6 ++--- daemon/gitlab_test.go | 23 ++++++++--------- daemon/spawner_test.go | 2 +- deploy/deploy.sh | 6 ++--- e2e/fake-gitlab.mjs | 50 ++++++++++++++++++------------------ e2e/scenarios/gitlab.mjs | 4 +-- 10 files changed, 79 insertions(+), 79 deletions(-) diff --git a/daemon/Dockerfile b/daemon/Dockerfile index c147f63..b9f0853 100644 --- a/daemon/Dockerfile +++ b/daemon/Dockerfile @@ -2,7 +2,7 @@ # docker build -f daemon/Dockerfile -t lvmh-daemon:latest . # To bake the real web UI, copy web/dist over the placeholder first: # cp -r web/dist daemon/webdist/ -FROM golang:1.24-alpine AS build +FROM golang:1.26-alpine AS build WORKDIR /src COPY daemon/go.mod daemon/go.sum ./ RUN go mod download diff --git a/daemon/api_extra_test.go b/daemon/api_extra_test.go index 7a2a0a5..66a83ce 100644 --- a/daemon/api_extra_test.go +++ b/daemon/api_extra_test.go @@ -188,20 +188,20 @@ func TestAPIDeleteContainerStopFailure(t *testing.T) { func newGitLabAPIServer(t *testing.T, broken bool) *httptest.Server { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Private-Token") != "pat-good" { + mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token pat-good" { w.WriteHeader(http.StatusUnauthorized) return } - _, _ = io.WriteString(w, `{"username":"alice"}`) + _, _ = io.WriteString(w, `{"login":"alice"}`) }) - mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/v1/user/repos", func(w http.ResponseWriter, r *http.Request) { if broken { w.WriteHeader(http.StatusInternalServerError) return } - _, _ = io.WriteString(w, `[{"path_with_namespace":"g/p","name":"P","namespace":{"path":"g"}, - "last_activity_at":"2024-01-01T00:00:00Z","web_url":"https://gl/g/p","default_branch":"main"}]`) + _, _ = io.WriteString(w, `[{"full_name":"g/p","name":"P","owner":{"login":"g"}, + "updated_at":"2024-01-01T00:00:00Z","html_url":"https://gl/g/p","default_branch":"main"}]`) }) up := httptest.NewServer(mux) t.Cleanup(up.Close) diff --git a/daemon/docker.go b/daemon/docker.go index 003687d..d8cd3d7 100644 --- a/daemon/docker.go +++ b/daemon/docker.go @@ -288,7 +288,7 @@ func (s *Spawner) cloneURL(repo string) (string, error) { return "", err } if token, ok, _ := s.store.GetSetting(settingGitLabToken); ok && token != "" && u.User == nil { - u.User = url.UserPassword("oauth2", token) + u.User = url.User(token) } return u.String(), nil } diff --git a/daemon/gitlab.go b/daemon/gitlab.go index f1dbc68..a540375 100644 --- a/daemon/gitlab.go +++ b/daemon/gitlab.go @@ -38,16 +38,17 @@ type GitLabRepo struct { 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"` +// giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we +// map from. +type giteaRepo struct { + FullName string `json:"full_name"` + Name string `json:"name"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + UpdatedAt string `json:"updated_at"` + HTMLURL string `json:"html_url"` + DefaultBranch string `json:"default_branch"` } // GitLab stores/validates the PAT in the settings table and lists projects. @@ -92,24 +93,24 @@ func (g *GitLab) Status() map[string]any { const settingGitLabUsername string = "gitlab_username" -// Connect validates the PAT against /api/v4/user and stores it. +// Connect validates the token against /api/v1/user and stores it. func (g *GitLab) Connect(ctx context.Context, token string) (string, error) { var user struct { - Username string `json:"username"` + Login string `json:"login"` } - if err := g.do(ctx, "/api/v4/user", token, &user); err != nil { + if err := g.do(ctx, "/api/v1/user", token, &user); err != nil { return "", err } - if user.Username == "" { - return "", &GitLabError{msg: "gitlab returned no username for token"} + if user.Login == "" { + return "", &GitLabError{msg: "gitea returned no login for token"} } if err := g.store.SetSetting(settingGitLabToken, token); err != nil { return "", err } - if err := g.store.SetSetting(settingGitLabUsername, user.Username); err != nil { + if err := g.store.SetSetting(settingGitLabUsername, user.Login); err != nil { return "", err } - return user.Username, nil + return user.Login, nil } // Disconnect drops the stored PAT. @@ -138,22 +139,22 @@ func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) { 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) + var projects []giteaRepo + path := fmt.Sprintf("/api/v1/user/repos?limit=%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 == "" { + if p.FullName == "" { continue } repos = append(repos, GitLabRepo{ - Path: p.PathWithNamespace, + Path: p.FullName, Name: p.Name, - Namespace: p.Namespace.Path, - LastActivityAt: p.LastActivityAt, - WebURL: p.WebURL, + Namespace: p.Owner.Login, + LastActivityAt: p.UpdatedAt, + WebURL: p.HTMLURL, DefaultBranch: p.DefaultBranch, }) } @@ -168,7 +169,7 @@ func (g *GitLab) do(ctx context.Context, path, token string, out any) error { if err != nil { return err } - req.Header.Set("Private-Token", token) + req.Header.Set("Authorization", "token "+token) resp, err := g.httpClient().Do(req) if err != nil { return &GitLabError{msg: fmt.Sprintf("gitlab request failed: %v", err)} diff --git a/daemon/gitlab_extra_test.go b/daemon/gitlab_extra_test.go index 77e5988..b54dce3 100644 --- a/daemon/gitlab_extra_test.go +++ b/daemon/gitlab_extra_test.go @@ -43,7 +43,7 @@ func TestGitLabConnectDecodeAndEmptyUser(t *testing.T) { want string }{ {"decode-failure", "not json", "decode"}, - {"empty-username", `{}`, "no username"}, + {"empty-login", `{}`, "no login"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -102,7 +102,7 @@ func TestGitLabStatusStoreFailure(t *testing.T) { func TestGitLabConnectStoreFailures(t *testing.T) { up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"username":"alice"}`)) + _, _ = w.Write([]byte(`{"login":"alice"}`)) })) t.Cleanup(up.Close) dead := openTestStore(t) @@ -115,7 +115,7 @@ func TestGitLabConnectStoreFailures(t *testing.T) { func TestGitLabReposSkipsEmptyPaths(t *testing.T) { up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`[{"path_with_namespace":"","name":"X"}]`)) + _, _ = w.Write([]byte(`[{"full_name":"","name":"X"}]`)) })) t.Cleanup(up.Close) store := openTestStore(t) diff --git a/daemon/gitlab_test.go b/daemon/gitlab_test.go index 51b3f75..9d2ed34 100644 --- a/daemon/gitlab_test.go +++ b/daemon/gitlab_test.go @@ -1,6 +1,6 @@ package main -// gitlab_test.go — PAT connect + project mapping against httptest upstream. +// gitlab_test.go — token connect + repo mapping against httptest upstream (Gitea v1). import ( "context" @@ -14,30 +14,29 @@ import ( func newGitLabUpstream(t *testing.T) (*httptest.Server, *GitLab) { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Private-Token") != "pat-good" { + mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token pat-good" { w.WriteHeader(http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"username":"alice"}`)) + _, _ = w.Write([]byte(`{"login":"alice"}`)) }) - mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Private-Token") != "pat-good" { + mux.HandleFunc("/api/v1/user/repos", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token pat-good" { w.WriteHeader(http.StatusUnauthorized) return } - q := r.URL.Query() - if q.Get("membership") != "true" || q.Get("order_by") != "last_activity_at" { + if r.URL.Query().Get("limit") == "" { w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[ - {"path_with_namespace":"team/b","name":"B","namespace":{"path":"team"}, - "last_activity_at":"2024-02-02T00:00:00Z","web_url":"https://gl/team/b","default_branch":"main"}, - {"path_with_namespace":"team/a","name":"A","namespace":{"path":"team"}, - "last_activity_at":"2024-03-03T00:00:00Z","web_url":"https://gl/team/a","default_branch":"trunk"} + {"full_name":"team/b","name":"B","owner":{"login":"team"}, + "updated_at":"2024-02-02T00:00:00Z","html_url":"https://gl/team/b","default_branch":"main"}, + {"full_name":"team/a","name":"A","owner":{"login":"team"}, + "updated_at":"2024-03-03T00:00:00Z","html_url":"https://gl/team/a","default_branch":"trunk"} ]`)) }) ts := httptest.NewServer(mux) diff --git a/daemon/spawner_test.go b/daemon/spawner_test.go index 63c2f69..064184e 100644 --- a/daemon/spawner_test.go +++ b/daemon/spawner_test.go @@ -271,7 +271,7 @@ func TestSpawnerCloneURLInjectsPAT(t *testing.T) { if err != nil { t.Fatalf("cloneURL: %v", err) } - if u != "https://oauth2:pat-1@gitlab.example/group/project.git" { + if u != "https://pat-1@gitlab.example/group/project.git" { t.Fatalf("cloneURL with PAT = %q", u) } } diff --git a/deploy/deploy.sh b/deploy/deploy.sh index d216e3e..7c17c60 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -26,7 +26,7 @@ rsync -az --delete \ echo "building daemon image + worker image on $REMOTE..." ssh "$REMOTE" "cd $REMOTE_DIR \ - && docker build -q -f daemon/Dockerfile -t lvmh-daemon:latest . \ - && docker build -q -f docker/worker.Dockerfile -t lvmh-worker:latest . \ - && docker compose up -d && docker compose ps" + && sudo docker build -q -f daemon/Dockerfile -t lvmh-daemon:latest . \ + && sudo docker build -q -f docker/worker.Dockerfile -t lvmh-worker:latest . \ + && sudo docker compose up -d && sudo docker compose ps" echo "Deployed: http://$REMOTE:8686" diff --git a/e2e/fake-gitlab.mjs b/e2e/fake-gitlab.mjs index ddf9c61..c266271 100644 --- a/e2e/fake-gitlab.mjs +++ b/e2e/fake-gitlab.mjs @@ -1,54 +1,54 @@ -// fake-gitlab.mjs — minimal GitLab v4 API stand-in on an ephemeral port. +// fake-gitlab.mjs — minimal Gitea v1 API stand-in on an ephemeral port. // Serves exactly the two endpoints the daemon calls (daemon/gitlab.go): -// GET /api/v4/user (PAT validation) -// GET /api/v4/projects (member projects listing) -// Projects are returned UNSORTED so the harness proves the daemon sorts by -// last_activity_at. +// GET /api/v1/user (token validation) +// GET /api/v1/user/repos (repo listing) +// Repos are returned UNSORTED so the harness proves the daemon sorts by +// updated_at. import * as http from "node:http"; -export const GOOD_PAT = "glpat-e2e-0123456789abcdef"; +export const GOOD_PAT = "gitea-e2e-0123456789abcdef"; const HTTP_OK = 200; const HTTP_UNAUTHORIZED = 401; const HTTP_NOT_FOUND = 404; export function startFakeGitLab() { - const seen = { userAuths: [], projectAuths: [] }; + const seen = { userAuths: [], repoAuths: [] }; const server = http.createServer((req, res) => { - const auth = String(req.headers["private-token"] ?? ""); + const auth = String(req.headers["authorization"] ?? ""); const send = (code, obj) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); }; - if (req.url.startsWith("/api/v4/user")) { - seen.userAuths.push(auth); - return auth === GOOD_PAT - ? send(HTTP_OK, { id: 1, username: "e2e-user", name: "E2E User" }) - : send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); - } - if (req.url.startsWith("/api/v4/projects")) { - seen.projectAuths.push(auth); - if (auth !== GOOD_PAT) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); + if (req.url.startsWith("/api/v1/user/repos")) { + seen.repoAuths.push(auth); + if (auth !== `token ${GOOD_PAT}`) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); const base = `http://127.0.0.1:${server.address().port}`; return send(HTTP_OK, [ { - path_with_namespace: "lvmh/beta", + full_name: "lvmh/beta", name: "beta", - namespace: { path: "lvmh" }, - last_activity_at: "2025-07-01T10:00:00Z", - web_url: `${base}/lvmh/beta`, + owner: { login: "lvmh" }, + updated_at: "2025-07-01T10:00:00Z", + html_url: `${base}/lvmh/beta`, default_branch: "main", }, { - path_with_namespace: "lvmh/alpha", + full_name: "lvmh/alpha", name: "alpha", - namespace: { path: "lvmh" }, - last_activity_at: "2025-08-01T10:00:00Z", - web_url: `${base}/lvmh/alpha`, + owner: { login: "lvmh" }, + updated_at: "2025-08-01T10:00:00Z", + html_url: `${base}/lvmh/alpha`, default_branch: "trunk", }, ]); } + if (req.url.startsWith("/api/v1/user")) { + seen.userAuths.push(auth); + return auth === `token ${GOOD_PAT}` + ? send(HTTP_OK, { id: 1, login: "e2e-user", name: "E2E User" }) + : send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); + } return send(HTTP_NOT_FOUND, { message: "404 Not Found" }); }); return new Promise((resolve) => { diff --git a/e2e/scenarios/gitlab.mjs b/e2e/scenarios/gitlab.mjs index ca636cb..8102596 100644 --- a/e2e/scenarios/gitlab.mjs +++ b/e2e/scenarios/gitlab.mjs @@ -17,8 +17,8 @@ export async function run(ctx) { `got ${connect.status} ${connect.text}`, ); r.check( - "daemon validated PAT against fake /api/v4/user", - gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === gitlab.pat, + "daemon validated token against fake /api/v1/user", + gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === `token ${gitlab.pat}`, JSON.stringify(gitlab.seen.userAuths), );