mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-04 06:25:32 +00:00
Follow-up to https://gitea.com/gitea/runner/pulls/1362, whose crash e2e missed because it only drove the daemon on Docker. - `dispatch`, `failure_paths` and `reusable_workflow` run through the daemon and `exec`, each on Docker and one shared k3s cluster. - Fixed sleeps become gates the test releases, the job timeout uses a 15s runner deadline, k3s boots alongside Gitea, Gitea's data is on tmpfs. - Outside a container, `make test-e2e` runs `go test` in a Linux container on its own network like CI, so local runs take the same paths. Local wall time: | | Before | After | |---|---|---| | Suite | 84s | 27–35s | | `job_timeout` | 64s | 17s | | `reconnect` | 22s | 4.5s | | `capacity/serial` | 20s | 2s | | `cli_shutdown_drains` | 14s | 2.3s | | `kubernetes` | 31s | 21–23s | _Written by Claude (Opus 5.5)._ --------- Co-authored-by: bircni <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1363 Reviewed-by: bircni <[email protected]> Co-authored-by: silverwind <[email protected]>
247 lines
7.2 KiB
Go
247 lines
7.2 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build e2e
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json/v2"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const pollInterval = 250 * time.Millisecond
|
|
|
|
type GiteaAPI struct {
|
|
baseURL string
|
|
token string
|
|
}
|
|
|
|
type ActionRun struct {
|
|
ID int64 `json:"id"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
Event string `json:"event"`
|
|
}
|
|
|
|
type ActionJob struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
RunnerName string `json:"runner_name"`
|
|
}
|
|
|
|
type ActionRunner struct {
|
|
Name string `json:"name"`
|
|
Labels []struct {
|
|
Name string `json:"name"`
|
|
} `json:"labels"`
|
|
}
|
|
|
|
func (a *GiteaAPI) CreateRepo(ctx context.Context, name string) error {
|
|
body := map[string]any{"name": name, "auto_init": true}
|
|
return a.doJSON(ctx, http.MethodPost, "/api/v1/user/repos", body, nil)
|
|
}
|
|
|
|
func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message string) error {
|
|
return a.CreateFiles(ctx, repo, map[string]string{path: content}, message)
|
|
}
|
|
|
|
func (a *GiteaAPI) CreateFiles(ctx context.Context, repo string, files map[string]string, message string) error {
|
|
var operations []map[string]string
|
|
for path, content := range files {
|
|
operations = append(operations, map[string]string{"operation": "create", "path": path, "content": base64.StdEncoding.EncodeToString([]byte(content))})
|
|
}
|
|
body := map[string]any{"files": operations, "message": message}
|
|
return a.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/contents", giteaAdminUser, repo), body, nil)
|
|
}
|
|
|
|
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
|
|
body := map[string]any{"tag_name": name}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/tags", giteaAdminUser, repo)
|
|
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
|
}
|
|
|
|
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
|
body := map[string]any{"data": value}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
|
return a.doJSON(ctx, http.MethodPut, url, body, nil)
|
|
}
|
|
|
|
func (a *GiteaAPI) DefaultBranch(ctx context.Context, repo string) (string, error) {
|
|
var resp struct {
|
|
DefaultBranch string `json:"default_branch"`
|
|
}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s", giteaAdminUser, repo)
|
|
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
|
|
return "", err
|
|
}
|
|
return resp.DefaultBranch, nil
|
|
}
|
|
|
|
func (a *GiteaAPI) CreateVariable(ctx context.Context, repo, name, value string) error {
|
|
body := map[string]any{"value": value}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", giteaAdminUser, repo, name)
|
|
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
|
}
|
|
|
|
func (a *GiteaAPI) DispatchWorkflow(ctx context.Context, repo, workflowID, ref string, inputs map[string]string) error {
|
|
body := map[string]any{"ref": ref}
|
|
if len(inputs) > 0 {
|
|
body["inputs"] = inputs
|
|
}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", giteaAdminUser, repo, workflowID)
|
|
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
|
}
|
|
|
|
var ErrCancelUnsupported = errors.New("run cancellation is unsupported by this gitea version")
|
|
|
|
func (a *GiteaAPI) CancelRun(ctx context.Context, repo string, runID int64) error {
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/cancel", giteaAdminUser, repo, runID)
|
|
err := a.doJSON(ctx, http.MethodPost, url, nil, nil)
|
|
var status statusError
|
|
if errors.As(err, &status) && status.routeAbsent() {
|
|
return ErrCancelUnsupported
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *GiteaAPI) Runs(ctx context.Context, repo string) ([]ActionRun, error) {
|
|
var resp struct {
|
|
WorkflowRuns []ActionRun `json:"workflow_runs"`
|
|
}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?limit=1", giteaAdminUser, repo)
|
|
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.WorkflowRuns, nil
|
|
}
|
|
|
|
func (a *GiteaAPI) WaitForRunConclusion(ctx context.Context, repo string, runID int64, timeout time.Duration) (*ActionRun, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", giteaAdminUser, repo, runID)
|
|
for {
|
|
var run ActionRun
|
|
if err := a.doJSON(ctx, http.MethodGet, url, nil, &run); err != nil {
|
|
return nil, err
|
|
}
|
|
if run.Status == "completed" {
|
|
return &run, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("run %d did not complete within %s (last status %q): %w", runID, timeout, run.Status, ctx.Err())
|
|
case <-time.After(pollInterval):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *GiteaAPI) Jobs(ctx context.Context, repo string, runID int64) ([]ActionJob, error) {
|
|
var resp struct {
|
|
Jobs []ActionJob `json:"jobs"`
|
|
}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", giteaAdminUser, repo, runID)
|
|
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Jobs, nil
|
|
}
|
|
|
|
func (a *GiteaAPI) Runners(ctx context.Context, repo string) ([]ActionRunner, error) {
|
|
var resp struct {
|
|
Runners []ActionRunner `json:"runners"`
|
|
}
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", giteaAdminUser, repo)
|
|
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Runners, nil
|
|
}
|
|
|
|
func (a *GiteaAPI) JobLogs(ctx context.Context, repo string, jobID int64) (string, error) {
|
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs", giteaAdminUser, repo, jobID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "token "+a.token)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
return "", statusError{method: http.MethodGet, path: url, code: resp.StatusCode, body: string(body)}
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
func (a *GiteaAPI) doJSON(ctx context.Context, method, path string, reqBody, respBody any) error {
|
|
var bodyReader io.Reader
|
|
if reqBody != nil {
|
|
encoded, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bodyReader = bytes.NewReader(encoded)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, bodyReader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+a.token)
|
|
if reqBody != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
return statusError{method: method, path: path, code: resp.StatusCode, body: string(body)}
|
|
}
|
|
if respBody != nil && len(body) > 0 {
|
|
return json.Unmarshal(body, respBody)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type statusError struct {
|
|
method, path string
|
|
code int
|
|
body string
|
|
}
|
|
|
|
func (e statusError) Error() string {
|
|
return fmt.Sprintf("%s %s: %d: %s", e.method, e.path, e.code, e.body)
|
|
}
|
|
|
|
func (e statusError) routeAbsent() bool {
|
|
return e.code == http.StatusNotFound && strings.TrimSpace(e.body) == "404 page not found"
|
|
}
|