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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user