daemon: Gitea v1 API adapter (git.westphal.fr is Gitea, not GitLab); fix golang Dockerfile to 1.26; sudo docker in deploy

This commit is contained in:
Raphael Westphal
2026-08-18 15:07:02 +02:00
parent bcd976a8ac
commit 49840225e2
10 changed files with 79 additions and 79 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# docker build -f daemon/Dockerfile -t lvmh-daemon:latest . # docker build -f daemon/Dockerfile -t lvmh-daemon:latest .
# To bake the real web UI, copy web/dist over the placeholder first: # To bake the real web UI, copy web/dist over the placeholder first:
# cp -r web/dist daemon/webdist/ # cp -r web/dist daemon/webdist/
FROM golang:1.24-alpine AS build FROM golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
COPY daemon/go.mod daemon/go.sum ./ COPY daemon/go.mod daemon/go.sum ./
RUN go mod download RUN go mod download
+6 -6
View File
@@ -188,20 +188,20 @@ func TestAPIDeleteContainerStopFailure(t *testing.T) {
func newGitLabAPIServer(t *testing.T, broken bool) *httptest.Server { func newGitLabAPIServer(t *testing.T, broken bool) *httptest.Server {
t.Helper() t.Helper()
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Private-Token") != "pat-good" { if r.Header.Get("Authorization") != "token pat-good" {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return 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 { if broken {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
_, _ = io.WriteString(w, `[{"path_with_namespace":"g/p","name":"P","namespace":{"path":"g"}, _, _ = io.WriteString(w, `[{"full_name":"g/p","name":"P","owner":{"login":"g"},
"last_activity_at":"2024-01-01T00:00:00Z","web_url":"https://gl/g/p","default_branch":"main"}]`) "updated_at":"2024-01-01T00:00:00Z","html_url":"https://gl/g/p","default_branch":"main"}]`)
}) })
up := httptest.NewServer(mux) up := httptest.NewServer(mux)
t.Cleanup(up.Close) t.Cleanup(up.Close)
+1 -1
View File
@@ -288,7 +288,7 @@ func (s *Spawner) cloneURL(repo string) (string, error) {
return "", err return "", err
} }
if token, ok, _ := s.store.GetSetting(settingGitLabToken); ok && token != "" && u.User == nil { 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 return u.String(), nil
} }
+24 -23
View File
@@ -38,15 +38,16 @@ type GitLabRepo struct {
DefaultBranch string `json:"defaultBranch"` DefaultBranch string `json:"defaultBranch"`
} }
// gitlabProject is the subset of the upstream projects API we map from. // giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we
type gitlabProject struct { // map from.
PathWithNamespace string `json:"path_with_namespace"` type giteaRepo struct {
FullName string `json:"full_name"`
Name string `json:"name"` Name string `json:"name"`
Namespace struct { Owner struct {
Path string `json:"path"` Login string `json:"login"`
} `json:"namespace"` } `json:"owner"`
LastActivityAt string `json:"last_activity_at"` UpdatedAt string `json:"updated_at"`
WebURL string `json:"web_url"` HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"` DefaultBranch string `json:"default_branch"`
} }
@@ -92,24 +93,24 @@ func (g *GitLab) Status() map[string]any {
const settingGitLabUsername string = "gitlab_username" 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) { func (g *GitLab) Connect(ctx context.Context, token string) (string, error) {
var user struct { 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 return "", err
} }
if user.Username == "" { if user.Login == "" {
return "", &GitLabError{msg: "gitlab returned no username for token"} return "", &GitLabError{msg: "gitea returned no login for token"}
} }
if err := g.store.SetSetting(settingGitLabToken, token); err != nil { if err := g.store.SetSetting(settingGitLabToken, token); err != nil {
return "", err 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 "", err
} }
return user.Username, nil return user.Login, nil
} }
// Disconnect drops the stored PAT. // Disconnect drops the stored PAT.
@@ -138,22 +139,22 @@ func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var projects []gitlabProject var projects []giteaRepo
path := fmt.Sprintf("/api/v4/projects?membership=true&order_by=last_activity_at&per_page=%d", projectsPerPage) path := fmt.Sprintf("/api/v1/user/repos?limit=%d", projectsPerPage)
if err := g.do(ctx, path, token, &projects); err != nil { if err := g.do(ctx, path, token, &projects); err != nil {
return nil, err return nil, err
} }
repos := make([]GitLabRepo, 0, len(projects)) repos := make([]GitLabRepo, 0, len(projects))
for _, p := range projects { for _, p := range projects {
if p.PathWithNamespace == "" { if p.FullName == "" {
continue continue
} }
repos = append(repos, GitLabRepo{ repos = append(repos, GitLabRepo{
Path: p.PathWithNamespace, Path: p.FullName,
Name: p.Name, Name: p.Name,
Namespace: p.Namespace.Path, Namespace: p.Owner.Login,
LastActivityAt: p.LastActivityAt, LastActivityAt: p.UpdatedAt,
WebURL: p.WebURL, WebURL: p.HTMLURL,
DefaultBranch: p.DefaultBranch, DefaultBranch: p.DefaultBranch,
}) })
} }
@@ -168,7 +169,7 @@ func (g *GitLab) do(ctx context.Context, path, token string, out any) error {
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Private-Token", token) req.Header.Set("Authorization", "token "+token)
resp, err := g.httpClient().Do(req) resp, err := g.httpClient().Do(req)
if err != nil { if err != nil {
return &GitLabError{msg: fmt.Sprintf("gitlab request failed: %v", err)} return &GitLabError{msg: fmt.Sprintf("gitlab request failed: %v", err)}
+3 -3
View File
@@ -43,7 +43,7 @@ func TestGitLabConnectDecodeAndEmptyUser(t *testing.T) {
want string want string
}{ }{
{"decode-failure", "not json", "decode"}, {"decode-failure", "not json", "decode"},
{"empty-username", `{}`, "no username"}, {"empty-login", `{}`, "no login"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -102,7 +102,7 @@ func TestGitLabStatusStoreFailure(t *testing.T) {
func TestGitLabConnectStoreFailures(t *testing.T) { func TestGitLabConnectStoreFailures(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) t.Cleanup(up.Close)
dead := openTestStore(t) dead := openTestStore(t)
@@ -115,7 +115,7 @@ func TestGitLabConnectStoreFailures(t *testing.T) {
func TestGitLabReposSkipsEmptyPaths(t *testing.T) { func TestGitLabReposSkipsEmptyPaths(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) t.Cleanup(up.Close)
store := openTestStore(t) store := openTestStore(t)
+11 -12
View File
@@ -1,6 +1,6 @@
package main 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 ( import (
"context" "context"
@@ -14,30 +14,29 @@ import (
func newGitLabUpstream(t *testing.T) (*httptest.Server, *GitLab) { func newGitLabUpstream(t *testing.T) (*httptest.Server, *GitLab) {
t.Helper() t.Helper()
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Private-Token") != "pat-good" { if r.Header.Get("Authorization") != "token pat-good" {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
w.Header().Set("Content-Type", "application/json") 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) { mux.HandleFunc("/api/v1/user/repos", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Private-Token") != "pat-good" { if r.Header.Get("Authorization") != "token pat-good" {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
q := r.URL.Query() if r.URL.Query().Get("limit") == "" {
if q.Get("membership") != "true" || q.Get("order_by") != "last_activity_at" {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[ _, _ = w.Write([]byte(`[
{"path_with_namespace":"team/b","name":"B","namespace":{"path":"team"}, {"full_name":"team/b","name":"B","owner":{"login":"team"},
"last_activity_at":"2024-02-02T00:00:00Z","web_url":"https://gl/team/b","default_branch":"main"}, "updated_at":"2024-02-02T00:00:00Z","html_url":"https://gl/team/b","default_branch":"main"},
{"path_with_namespace":"team/a","name":"A","namespace":{"path":"team"}, {"full_name":"team/a","name":"A","owner":{"login":"team"},
"last_activity_at":"2024-03-03T00:00:00Z","web_url":"https://gl/team/a","default_branch":"trunk"} "updated_at":"2024-03-03T00:00:00Z","html_url":"https://gl/team/a","default_branch":"trunk"}
]`)) ]`))
}) })
ts := httptest.NewServer(mux) ts := httptest.NewServer(mux)
+1 -1
View File
@@ -271,7 +271,7 @@ func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("cloneURL: %v", err) 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) t.Fatalf("cloneURL with PAT = %q", u)
} }
} }
+3 -3
View File
@@ -26,7 +26,7 @@ rsync -az --delete \
echo "building daemon image + worker image on $REMOTE..." echo "building daemon image + worker image on $REMOTE..."
ssh "$REMOTE" "cd $REMOTE_DIR \ ssh "$REMOTE" "cd $REMOTE_DIR \
&& docker build -q -f daemon/Dockerfile -t lvmh-daemon:latest . \ && sudo docker build -q -f daemon/Dockerfile -t lvmh-daemon:latest . \
&& docker build -q -f docker/worker.Dockerfile -t lvmh-worker:latest . \ && sudo docker build -q -f docker/worker.Dockerfile -t lvmh-worker:latest . \
&& docker compose up -d && docker compose ps" && sudo docker compose up -d && sudo docker compose ps"
echo "Deployed: http://$REMOTE:8686" echo "Deployed: http://$REMOTE:8686"
+25 -25
View File
@@ -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): // Serves exactly the two endpoints the daemon calls (daemon/gitlab.go):
// GET /api/v4/user (PAT validation) // GET /api/v1/user (token validation)
// GET /api/v4/projects (member projects listing) // GET /api/v1/user/repos (repo listing)
// Projects are returned UNSORTED so the harness proves the daemon sorts by // Repos are returned UNSORTED so the harness proves the daemon sorts by
// last_activity_at. // updated_at.
import * as http from "node:http"; 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_OK = 200;
const HTTP_UNAUTHORIZED = 401; const HTTP_UNAUTHORIZED = 401;
const HTTP_NOT_FOUND = 404; const HTTP_NOT_FOUND = 404;
export function startFakeGitLab() { export function startFakeGitLab() {
const seen = { userAuths: [], projectAuths: [] }; const seen = { userAuths: [], repoAuths: [] };
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const auth = String(req.headers["private-token"] ?? ""); const auth = String(req.headers["authorization"] ?? "");
const send = (code, obj) => { const send = (code, obj) => {
res.writeHead(code, { "Content-Type": "application/json" }); res.writeHead(code, { "Content-Type": "application/json" });
res.end(JSON.stringify(obj)); res.end(JSON.stringify(obj));
}; };
if (req.url.startsWith("/api/v4/user")) { if (req.url.startsWith("/api/v1/user/repos")) {
seen.userAuths.push(auth); seen.repoAuths.push(auth);
return auth === GOOD_PAT if (auth !== `token ${GOOD_PAT}`) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
? 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" });
const base = `http://127.0.0.1:${server.address().port}`; const base = `http://127.0.0.1:${server.address().port}`;
return send(HTTP_OK, [ return send(HTTP_OK, [
{ {
path_with_namespace: "lvmh/beta", full_name: "lvmh/beta",
name: "beta", name: "beta",
namespace: { path: "lvmh" }, owner: { login: "lvmh" },
last_activity_at: "2025-07-01T10:00:00Z", updated_at: "2025-07-01T10:00:00Z",
web_url: `${base}/lvmh/beta`, html_url: `${base}/lvmh/beta`,
default_branch: "main", default_branch: "main",
}, },
{ {
path_with_namespace: "lvmh/alpha", full_name: "lvmh/alpha",
name: "alpha", name: "alpha",
namespace: { path: "lvmh" }, owner: { login: "lvmh" },
last_activity_at: "2025-08-01T10:00:00Z", updated_at: "2025-08-01T10:00:00Z",
web_url: `${base}/lvmh/alpha`, html_url: `${base}/lvmh/alpha`,
default_branch: "trunk", 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 send(HTTP_NOT_FOUND, { message: "404 Not Found" });
}); });
return new Promise((resolve) => { return new Promise((resolve) => {
+2 -2
View File
@@ -17,8 +17,8 @@ export async function run(ctx) {
`got ${connect.status} ${connect.text}`, `got ${connect.status} ${connect.text}`,
); );
r.check( r.check(
"daemon validated PAT against fake /api/v4/user", "daemon validated token against fake /api/v1/user",
gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === gitlab.pat, gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === `token ${gitlab.pat}`,
JSON.stringify(gitlab.seen.userAuths), JSON.stringify(gitlab.seen.userAuths),
); );