CC1 (#1135) promoted the easy-to-merge shared internals (semver, render, placement, labels) but explicitly DEFERRED the Gitea HTTP client because the four Group C controllers (slices C1-C4) shipped four divergent client surfaces: * organization (C1): Org+Repo CRUD with `Org`/`Repo` struct returns; `EnsureRepo(ctx, org, name, desc, private) (Repo, error)` * blueprint (C3): File CRUD via `*FileResponse`; `EnsureRepo(ctx, org, repo) error` * environment (C2): File CRUD via `*FileContent` + `UpsertFile` (with committer attribution); BaseURL must include `/api/v1` * application (C4): File CRUD via `*FileResponse`; `EnsureRepo(ctx, org, repo) error` + `EnsureBranch` The two `EnsureRepo` shapes collide on signature. CC2's task: design the SUPERSET, migrate every controller without behavior change. What CC2 ships: * `core/controllers/internal/gitea/{client,DESIGN}.go` + `client_test.go` — single unified Client. The SUPERSET method list: Org+Repo CRUD (won from): C1 — only implementer GetOrg(ctx, slug) (Org, error) CreateOrg(ctx, slug, fullName, desc, vis) (Org, error) EnsureOrg(ctx, slug, fullName, desc, vis) (Org, error) GetRepo(ctx, owner, name) (Repo, error) CreateRepo(ctx, org, name, desc, private, autoInit, defBranch) (Repo, error) EnsureRepo(ctx, org, name, desc, private) (Repo, error) ← C1 surface; C3+C4 callers discard the Repo EnsureBranch(ctx, org, repo, branch) error (won from): C4 GetFile(ctx, org, repo, branch, path) (File, error) (won from): C2 — has repo-vs-file 404 distinction PutFile(...) (File, committed bool, err error) (won from): C4 signature + C1 byte-equal short-circuit + C2 PutFileOpts for committer DeleteFile(ctx, org, repo, branch, path, msg) (bool, error) (won from): C3/C4 (identical) Errors: ErrOrgNotFound, ErrRepoNotFound, ErrFileNotFound + HTTPError + IsNotFound() + IsConflict() — covers every prior helper. BaseURL semantics canonicalized: takes Gitea root WITHOUT `/api/v1`; client appends internally. environment-controller's GITEA_API_URL default updated to drop the `/api/v1` suffix. 26 tests covering every reconciler-relevant code path including: * EnsureOrg / EnsureRepo / EnsureBranch find-or-create + 422/409 races * PutFile create / update / byte-equal short-circuit / with author * GetFile / DeleteFile typed sentinels (ErrFileNotFound vs ErrRepoNotFound) * IsNotFound / IsConflict coverage of typed sentinels + HTTPError * Per-controller migration: * organization (C1): EnsureOrg/EnsureRepo same; PutFile arg-order swap (path↔branch — C1 was the outlier) and `(_, _, err :=)` triple. 1 reconciler call site updated. * blueprint (C3): EnsureRepo wrapped with the canonical description literal + private=false (catalog Org). 1 reconciler call site. * environment (C2): GiteaClient interface updated; UpsertFile → PutFile with PutFileOpts for committer attribution; *Org → Org. cmd/main.go drops trailing `/api/v1` from default GITEA_API_URL. 1 reconciler call site + 1 fake. * application (C4): Gitea interface updated to match new shape; EnsureRepo wrapped with description + private=true literal. 1 reconciler call site + 1 fake. * Each per-controller `internal/gitea/` directory deleted (4 dirs, ~2400 LoC removed). Test-coverage delta: Pre-CC2 client tests: 4 + 4 + 10 + 5 = 23 tests across 4 packages Post-CC2 shared tests: 26 tests in one package (+3 net) Per-controller tests: unchanged in count, all still GREEN Verified locally: go vet ./... — clean go test -count=1 -race ./... — every package GREEN go build per controller cmd/ — all 5 binaries link Architecture rules preserved: * No behavior change for any existing call site (the SUPERSET is strictly a union; reconciler logic byte-identical). * Single shared go.mod; no new module path. * Idempotency anchor (PutFile byte-equal short-circuit) preserved. * No new Gitea API methods beyond union of existing usage. * No deploy-manifest changes (env-controller's URL drop is cmd-side default; no chart template touches GITEA_API_URL yet). Co-authored-by: hatiyildiz <hatiyildiz@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66fd0bbae3
commit
1b29c7178e
@ -43,6 +43,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@ -58,7 +59,7 @@ import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/application/internal/controller"
|
||||
"github.com/openova-io/openova/core/controllers/application/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -92,7 +93,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
giteaClient := gitea.NewClient(env("GITEA_API_URL", "http://gitea-http.gitea.svc.cluster.local:3000"), os.Getenv("GITEA_TOKEN"))
|
||||
giteaClient := gitea.New(env("GITEA_API_URL", "http://gitea-http.gitea.svc.cluster.local:3000"), os.Getenv("GITEA_TOKEN"))
|
||||
|
||||
r := controller.New(dyn, giteaClient, giteaClassifier{}, cfg, logger)
|
||||
|
||||
@ -131,7 +132,7 @@ func loadConfigFromEnv() controller.Config {
|
||||
type giteaClassifier struct{}
|
||||
|
||||
func (giteaClassifier) IsNotFound(err error) bool { return gitea.IsNotFound(err) }
|
||||
func (giteaClassifier) IsOrgNotFound(err error) bool { return err == gitea.ErrOrgNotFound }
|
||||
func (giteaClassifier) IsOrgNotFound(err error) bool { return errors.Is(err, gitea.ErrOrgNotFound) }
|
||||
|
||||
// runProbes runs a tiny http.Server with /healthz, /readyz, and a
|
||||
// stub /metrics endpoint. Per Inviolable Principle #4 the addresses
|
||||
|
||||
@ -59,10 +59,11 @@ import (
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/application/internal/validate"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/placement"
|
||||
"github.com/openova-io/openova/core/controllers/internal/render"
|
||||
"github.com/openova-io/openova/core/controllers/internal/semver"
|
||||
"github.com/openova-io/openova/core/controllers/application/internal/validate"
|
||||
)
|
||||
|
||||
// GVR pins for the three CRDs the controller reads. Storage versions
|
||||
@ -136,9 +137,9 @@ const FinalizerName = "application.apps.openova.io/finalizer"
|
||||
// for idempotency, so the controller never needs to read existing
|
||||
// content directly.
|
||||
type Gitea interface {
|
||||
EnsureRepo(ctx context.Context, org, repo string) error
|
||||
EnsureRepo(ctx context.Context, org, name, description string, private bool) (gitea.Repo, error)
|
||||
EnsureBranch(ctx context.Context, org, repo, branch string) error
|
||||
PutFile(ctx context.Context, org, repo, branch, path string, content []byte, message string) (sha string, committed bool, err error)
|
||||
PutFile(ctx context.Context, org, repo, branch, path string, content []byte, message string, opts ...gitea.PutFileOpts) (file gitea.File, committed bool, err error)
|
||||
DeleteFile(ctx context.Context, org, repo, branch, path, message string) (bool, error)
|
||||
}
|
||||
|
||||
@ -418,7 +419,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, app *unstructured.Unstructur
|
||||
|
||||
// 8. Ensure the per-Application Gitea repo exists.
|
||||
branch := branchForEnvType(envSpec.EnvType)
|
||||
if err := r.Gitea.EnsureRepo(ctx, envSpec.OrganizationRef, app.GetName()); err != nil {
|
||||
if _, err := r.Gitea.EnsureRepo(ctx, envSpec.OrganizationRef, app.GetName(),
|
||||
"Application manifests — auto-managed by application-controller. Do not edit manually.",
|
||||
true); err != nil {
|
||||
if r.GiteaErrors != nil && r.GiteaErrors.IsOrgNotFound(err) {
|
||||
return r.markPending(ctx, app, ReasonOrgGiteaMissing,
|
||||
fmt.Sprintf("Gitea Org %q does not exist; organization-controller (C1) creates it",
|
||||
|
||||
@ -39,6 +39,8 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
k8stypes "k8s.io/apimachinery/pkg/types"
|
||||
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
)
|
||||
|
||||
// fakeGitea is a deterministic test double for the Gitea interface.
|
||||
@ -81,11 +83,11 @@ func newFakeGitea() *fakeGitea {
|
||||
|
||||
func (f *fakeGitea) repoKey(org, repo string) string { return org + "/" + repo }
|
||||
|
||||
func (f *fakeGitea) EnsureRepo(_ context.Context, org, repo string) error {
|
||||
func (f *fakeGitea) EnsureRepo(_ context.Context, org, repo, _ string, _ bool) (gitea.Repo, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.orgsExist[org] {
|
||||
return errOrgNotFound
|
||||
return gitea.Repo{}, errOrgNotFound
|
||||
}
|
||||
key := f.repoKey(org, repo)
|
||||
if _, ok := f.repos[key]; !ok {
|
||||
@ -93,7 +95,7 @@ func (f *fakeGitea) EnsureRepo(_ context.Context, org, repo string) error {
|
||||
"main": {},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return gitea.Repo{Name: repo, FullName: key}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGitea) EnsureBranch(_ context.Context, org, repo, branch string) error {
|
||||
@ -109,11 +111,11 @@ func (f *fakeGitea) EnsureBranch(_ context.Context, org, repo, branch string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGitea) PutFile(_ context.Context, org, repo, branch, path string, content []byte, _ string) (string, bool, error) {
|
||||
func (f *fakeGitea) PutFile(_ context.Context, org, repo, branch, path string, content []byte, _ string, _ ...gitea.PutFileOpts) (gitea.File, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failOnPath != "" && path == f.failOnPath {
|
||||
return "", false, f.failPathErr
|
||||
return gitea.File{}, false, f.failPathErr
|
||||
}
|
||||
key := f.repoKey(org, repo)
|
||||
if _, ok := f.repos[key]; !ok {
|
||||
@ -124,11 +126,11 @@ func (f *fakeGitea) PutFile(_ context.Context, org, repo, branch, path string, c
|
||||
}
|
||||
existing, exists := f.repos[key][branch][path]
|
||||
if exists && string(existing) == string(content) {
|
||||
return "stable", false, nil
|
||||
return gitea.File{Path: path, SHA: "stable"}, false, nil
|
||||
}
|
||||
f.repos[key][branch][path] = append([]byte(nil), content...)
|
||||
f.puts++
|
||||
return "newsha", true, nil
|
||||
return gitea.File{Path: path, SHA: "newsha"}, true, nil
|
||||
}
|
||||
|
||||
func (f *fakeGitea) DeleteFile(_ context.Context, org, repo, branch, path, _ string) (bool, error) {
|
||||
|
||||
@ -1,384 +0,0 @@
|
||||
// Package gitea — minimal HTTP client for the Sovereign-local Gitea
|
||||
// instance, scoped to the operations the application-controller needs.
|
||||
//
|
||||
// Per docs/EPICS-1-6-unified-design.md §3.2.3 + §3.3, every Application
|
||||
// CR maps to exactly ONE per-Org Gitea repo:
|
||||
//
|
||||
// gitea.<location-code>.<sovereign-domain>/<org>/<app-name>
|
||||
//
|
||||
// On each reconcile pass the application-controller:
|
||||
//
|
||||
// 1. EnsureRepo(org, app) — create-if-missing, idempotent
|
||||
// 2. PutFile(org, app, branch, path, bytes, msg)
|
||||
// — write a manifest under
|
||||
// clusters/<host-cluster>/applications/<app>/{kustomization,helmrelease}.yaml
|
||||
// 3. DeleteFile(org, app, branch, path, msg)
|
||||
// — cascade-delete on Application CR
|
||||
// removal (Flux sees the missing
|
||||
// manifest and drains the workload).
|
||||
//
|
||||
// Why a separate package (and not a shared `core/controllers/internal/
|
||||
// gitea/`): the canon §1 + §2 prescribes a shared internal/. The 4
|
||||
// sibling Group C controllers each shipped their own copy because
|
||||
// no one of them was first-writer in time to claim the shared seam.
|
||||
// CC1 (Coordinator-led consolidation slice) will promote the union
|
||||
// surface to `core/controllers/internal/gitea/` after C4 lands. Until
|
||||
// then, each controller carries its own copy with a stable surface so
|
||||
// the consolidation is a renames-only patch.
|
||||
//
|
||||
// The surface here intentionally MIRRORS the blueprint-controller's
|
||||
// gitea.Client byte-for-byte for the operations C4 also needs
|
||||
// (GetFile / PutFile / DeleteFile / EnsureRepo) — see slice C3 brief
|
||||
// note in `core/controllers/blueprint/internal/gitea/client.go`.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a thin wrapper around http.Client targeting a Gitea
|
||||
// instance's `/api/v1` surface.
|
||||
type Client struct {
|
||||
// BaseURL is the Gitea root, e.g. "http://gitea-http.gitea:3000"
|
||||
// or "https://gitea.hfmp.openova.io".
|
||||
BaseURL string
|
||||
|
||||
// Token is a personal-access token with `repo` + `write:repository`
|
||||
// scopes for the per-Org Gitea Orgs the controller writes to. In
|
||||
// production this is wired from CATALYST_GITEA_TOKEN.
|
||||
Token string
|
||||
|
||||
// HTTP is the underlying client. Tests inject a httptest server.
|
||||
// Default: a 30s timeout client.
|
||||
HTTP *http.Client
|
||||
|
||||
// User-Agent emitted on every request. Defaults to
|
||||
// "openova-application-controller/1.0".
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// NewClient returns a Client with sensible defaults.
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
UserAgent: "openova-application-controller/1.0",
|
||||
}
|
||||
}
|
||||
|
||||
// FileResponse is the subset of Gitea's contents-API response we use.
|
||||
// Full schema:
|
||||
// https://docs.gitea.com/api#tag/repository/operation/repoGetContents
|
||||
type FileResponse struct {
|
||||
Path string `json:"path"`
|
||||
SHA string `json:"sha"`
|
||||
Content string `json:"content"` // base64-encoded
|
||||
Type string `json:"type"` // "file" | "dir" | "symlink" | "submodule"
|
||||
}
|
||||
|
||||
// commitFilePayload — body for create / update / delete file operations.
|
||||
type commitFilePayload struct {
|
||||
Message string `json:"message"`
|
||||
Content string `json:"content,omitempty"` // base64-encoded; omitempty for delete
|
||||
SHA string `json:"sha,omitempty"` // required for update + delete
|
||||
Branch string `json:"branch,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPError reports a non-2xx response from the Gitea API.
|
||||
type HTTPError struct {
|
||||
Method string
|
||||
URL string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
return fmt.Sprintf("gitea: %s %s: HTTP %d: %s", e.Method, e.URL, e.Status, e.Body)
|
||||
}
|
||||
|
||||
// IsNotFound reports whether err is a 404 response.
|
||||
func IsNotFound(err error) bool {
|
||||
var he *HTTPError
|
||||
if !errors.As(err, &he) {
|
||||
return false
|
||||
}
|
||||
return he.Status == http.StatusNotFound
|
||||
}
|
||||
|
||||
// IsConflict reports whether err is a 409 response (used by EnsureRepo
|
||||
// when a parallel reconcile / a stale watch already created the repo).
|
||||
func IsConflict(err error) bool {
|
||||
var he *HTTPError
|
||||
if !errors.As(err, &he) {
|
||||
return false
|
||||
}
|
||||
return he.Status == http.StatusConflict
|
||||
}
|
||||
|
||||
// do builds, sends, and decodes a Gitea API request. dst may be nil
|
||||
// when the caller doesn't care about the response body.
|
||||
func (c *Client) do(ctx context.Context, method, path string, body interface{}, dst interface{}) error {
|
||||
if c.BaseURL == "" {
|
||||
return errors.New("gitea: BaseURL is empty")
|
||||
}
|
||||
if c.Token == "" {
|
||||
return errors.New("gitea: Token is empty")
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: marshal body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
fullURL := c.BaseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: %s %s: %w", method, fullURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return &HTTPError{
|
||||
Method: method,
|
||||
URL: fullURL,
|
||||
Status: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
}
|
||||
}
|
||||
if dst != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, dst); err != nil {
|
||||
return fmt.Errorf("gitea: decode response from %s: %w", fullURL, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFile reads a file from a repo at the given branch.
|
||||
func (c *Client) GetFile(ctx context.Context, org, repo, branch, path string) (*FileResponse, error) {
|
||||
q := url.Values{}
|
||||
if branch != "" {
|
||||
q.Set("ref", branch)
|
||||
}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if qs := q.Encode(); qs != "" {
|
||||
endpoint += "?" + qs
|
||||
}
|
||||
out := &FileResponse{}
|
||||
if err := c.do(ctx, http.MethodGet, endpoint, nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PutFile creates or updates a file at path. If the file already
|
||||
// exists, the call performs an update by passing the current SHA.
|
||||
// Idempotent: if content matches the existing file byte-for-byte, no
|
||||
// API call is made (saves a write to Gitea + the etcd watch event).
|
||||
//
|
||||
// Returns (newSHA, committed). committed=false when the existing file
|
||||
// was already byte-equal — surfaces to the controller's idempotency
|
||||
// counter so re-reconcile on a steady state = 0 writes.
|
||||
func (c *Client) PutFile(ctx context.Context, org, repo, branch, path string, content []byte, message string) (sha string, committed bool, err error) {
|
||||
encoded := base64.StdEncoding.EncodeToString(content)
|
||||
|
||||
// Probe existing.
|
||||
existing, err := c.GetFile(ctx, org, repo, branch, path)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Decode existing content; skip the write if identical.
|
||||
decoded, decErr := base64.StdEncoding.DecodeString(strings.ReplaceAll(existing.Content, "\n", ""))
|
||||
if decErr == nil && bytes.Equal(decoded, content) {
|
||||
return existing.SHA, false, nil
|
||||
}
|
||||
// Update path.
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
Content: encoded,
|
||||
SHA: existing.SHA,
|
||||
Branch: branch,
|
||||
}
|
||||
out := &FileResponse{}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if err := c.do(ctx, http.MethodPut, endpoint, body, out); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return out.SHA, true, nil
|
||||
case IsNotFound(err):
|
||||
// Create path.
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
Content: encoded,
|
||||
Branch: branch,
|
||||
}
|
||||
out := &FileResponse{}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if err := c.do(ctx, http.MethodPost, endpoint, body, out); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return out.SHA, true, nil
|
||||
default:
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFile removes path from the repo at branch. Idempotent: a 404
|
||||
// from the probe returns (true, nil) — the file is already absent.
|
||||
//
|
||||
// Returns (deleted, err). deleted=true when the file existed and was
|
||||
// deleted (or was already absent); deleted=false only on transport
|
||||
// failures.
|
||||
func (c *Client) DeleteFile(ctx context.Context, org, repo, branch, path, message string) (bool, error) {
|
||||
existing, err := c.GetFile(ctx, org, repo, branch, path)
|
||||
switch {
|
||||
case IsNotFound(err):
|
||||
return true, nil
|
||||
case err != nil:
|
||||
return false, err
|
||||
}
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
SHA: existing.SHA,
|
||||
Branch: branch,
|
||||
}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if err := c.do(ctx, http.MethodDelete, endpoint, body, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// EnsureRepo creates the repo if it doesn't exist. Idempotent.
|
||||
//
|
||||
// Per docs/NAMING-CONVENTION.md §11.2, the per-Org Gitea Org holds one
|
||||
// repo per Application at `<app-name>`. The application-controller
|
||||
// pre-creates this via this call before issuing the first PutFile.
|
||||
//
|
||||
// Returns nil on success. A 404 on the org itself is surfaced via
|
||||
// ErrOrgNotFound so the caller can re-queue with an explicit Pending
|
||||
// condition (organization-controller, slice C1, owns Org creation).
|
||||
func (c *Client) EnsureRepo(ctx context.Context, org, repo string) error {
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo))
|
||||
if err := c.do(ctx, http.MethodGet, endpoint, nil, nil); err == nil {
|
||||
return nil
|
||||
} else if !IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
// Create.
|
||||
createEndpoint := fmt.Sprintf("/api/v1/orgs/%s/repos", url.PathEscape(org))
|
||||
body := map[string]interface{}{
|
||||
"name": repo,
|
||||
"description": "Application manifests — auto-managed by application-controller. Do not edit manually.",
|
||||
"private": true, // per-Org per-App repo: tenant-scoped
|
||||
"auto_init": true, // ensures branch exists for first PutFile
|
||||
"default_branch": "main",
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, createEndpoint, body, nil); err != nil {
|
||||
// 404 on the create-under-org endpoint means the Org itself
|
||||
// doesn't exist yet — surface a typed error so the controller
|
||||
// re-queues with `OrgMissing` Pending instead of looping on a
|
||||
// transient.
|
||||
if IsNotFound(err) {
|
||||
return ErrOrgNotFound
|
||||
}
|
||||
// 409 = repo created by a parallel call between our GET probe
|
||||
// and the POST. Treat as success — the next GetFile/PutFile
|
||||
// will see the existing repo.
|
||||
if IsConflict(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrOrgNotFound is returned by EnsureRepo when the Org itself doesn't
|
||||
// exist on the Gitea instance. Callers use this to surface a Pending
|
||||
// condition with reason `OrgMissing`.
|
||||
var ErrOrgNotFound = errors.New("gitea: org not found")
|
||||
|
||||
// EnsureBranch ensures the named branch exists on the repo by branching
|
||||
// from the repo's default. Idempotent: 409 / 422 (already exists) is
|
||||
// treated as success.
|
||||
//
|
||||
// Used so writes to Environment-mapped branches (`develop`, `staging`)
|
||||
// don't fail when the auto-init path only created `main`.
|
||||
func (c *Client) EnsureBranch(ctx context.Context, org, repo, branch string) error {
|
||||
if branch == "" || branch == "main" {
|
||||
// `main` is created by EnsureRepo's auto_init.
|
||||
return nil
|
||||
}
|
||||
// Probe for the branch first.
|
||||
probeEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), url.PathEscape(branch))
|
||||
if err := c.do(ctx, http.MethodGet, probeEndpoint, nil, nil); err == nil {
|
||||
return nil
|
||||
} else if !IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
// Create from main.
|
||||
createEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/branches",
|
||||
url.PathEscape(org), url.PathEscape(repo))
|
||||
body := map[string]interface{}{
|
||||
"new_branch_name": branch,
|
||||
"old_branch_name": "main",
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, createEndpoint, body, nil); err != nil {
|
||||
if IsConflict(err) {
|
||||
return nil
|
||||
}
|
||||
// 422 (Unprocessable Entity) — Gitea returns this when the
|
||||
// branch already exists OR the source branch doesn't exist.
|
||||
// We've already confirmed by probe that the branch doesn't
|
||||
// exist, so 422 means a parallel create won.
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) && he.Status == http.StatusUnprocessableEntity {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathEscapeSegments escapes each path segment but preserves slashes.
|
||||
// `url.PathEscape` would encode the slashes, breaking Gitea's path
|
||||
// resolution. We need per-segment escaping for cases where a path
|
||||
// component contains a space or '#' / '?'.
|
||||
func pathEscapeSegments(p string) string {
|
||||
parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
for i, s := range parts {
|
||||
parts[i] = url.PathEscape(s)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
@ -1,205 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGiteaServer is a tiny in-memory Gitea-compatible HTTP server used
|
||||
// for unit-testing the client. Mirrors the fakeGiteaCounter shape from
|
||||
// slice C3 (blueprint-controller).
|
||||
type fakeGiteaServer struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// orgs that exist (Map[org]bool).
|
||||
orgs map[string]bool
|
||||
// repos that exist (Map[org/repo]bool).
|
||||
repos map[string]bool
|
||||
// branches per repo (Map[org/repo]Map[branch]bool).
|
||||
branches map[string]map[string]bool
|
||||
// files per (org, repo, branch, path) → content+sha.
|
||||
files map[string]fakeFile
|
||||
}
|
||||
|
||||
type fakeFile struct {
|
||||
content []byte
|
||||
sha string
|
||||
}
|
||||
|
||||
func newFakeGiteaServer() *fakeGiteaServer {
|
||||
return &fakeGiteaServer{
|
||||
orgs: map[string]bool{},
|
||||
repos: map[string]bool{},
|
||||
branches: map[string]map[string]bool{},
|
||||
files: map[string]fakeFile{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeGiteaServer) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := r.URL.Path
|
||||
|
||||
// /api/v1/repos/<org>/<repo> GET probe
|
||||
// /api/v1/orgs/<org>/repos POST create
|
||||
// /api/v1/repos/<org>/<repo>/contents/...
|
||||
// /api/v1/repos/<org>/<repo>/branches POST
|
||||
// /api/v1/repos/<org>/<repo>/branches/<n> GET probe
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v1/orgs/") && strings.HasSuffix(path, "/repos") && r.Method == http.MethodPost:
|
||||
parts := strings.Split(path, "/")
|
||||
org := parts[4]
|
||||
if !s.orgs[org] {
|
||||
http.Error(w, "org not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Body has `name`. We don't bother parsing — the test
|
||||
// only checks that EnsureRepo's POST went through.
|
||||
// The repo name is appended to the org+repo map; we
|
||||
// recover it by snooping the body.
|
||||
var name string
|
||||
b := make([]byte, r.ContentLength)
|
||||
r.Body.Read(b)
|
||||
// extremely cheap: find `"name":"<n>"`
|
||||
if i := strings.Index(string(b), `"name":"`); i >= 0 {
|
||||
rest := string(b)[i+8:]
|
||||
if j := strings.Index(rest, `"`); j >= 0 {
|
||||
name = rest[:j]
|
||||
}
|
||||
}
|
||||
s.repos[org+"/"+name] = true
|
||||
s.branches[org+"/"+name] = map[string]bool{"main": true}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
|
||||
case strings.HasPrefix(path, "/api/v1/repos/") && r.Method == http.MethodGet && !strings.Contains(path, "/contents/") && !strings.Contains(path, "/branches"):
|
||||
// GET /repos/<org>/<repo>
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/repos/"), "/")
|
||||
if len(parts) >= 2 {
|
||||
key := parts[0] + "/" + parts[1]
|
||||
if s.repos[key] {
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
|
||||
case strings.Contains(path, "/branches/") && r.Method == http.MethodGet:
|
||||
// GET /repos/<org>/<repo>/branches/<branch>
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/repos/"), "/")
|
||||
if len(parts) >= 4 {
|
||||
repoKey := parts[0] + "/" + parts[1]
|
||||
branch := parts[3]
|
||||
if s.branches[repoKey][branch] {
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
|
||||
case strings.HasSuffix(path, "/branches") && r.Method == http.MethodPost:
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/repos/"), "/")
|
||||
if len(parts) >= 3 {
|
||||
repoKey := parts[0] + "/" + parts[1]
|
||||
if s.branches[repoKey] == nil {
|
||||
s.branches[repoKey] = map[string]bool{}
|
||||
}
|
||||
// snoop new_branch_name
|
||||
b := make([]byte, r.ContentLength)
|
||||
r.Body.Read(b)
|
||||
if i := strings.Index(string(b), `"new_branch_name":"`); i >= 0 {
|
||||
rest := string(b)[i+19:]
|
||||
if j := strings.Index(rest, `"`); j >= 0 {
|
||||
s.branches[repoKey][rest[:j]] = true
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "unhandled "+r.Method+" "+path, http.StatusNotImplemented)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_EnsureRepo_OrgMissing(t *testing.T) {
|
||||
srv := newFakeGiteaServer()
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := NewClient(ts.URL, "test-token")
|
||||
err := c.EnsureRepo(context.Background(), "missing-org", "myapp")
|
||||
if !errors.Is(err, ErrOrgNotFound) {
|
||||
t.Errorf("expected ErrOrgNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_EnsureRepo_CreatesIfMissing(t *testing.T) {
|
||||
srv := newFakeGiteaServer()
|
||||
srv.orgs["acme"] = true
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := NewClient(ts.URL, "test-token")
|
||||
if err := c.EnsureRepo(context.Background(), "acme", "site"); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
if !srv.repos["acme/site"] {
|
||||
t.Error("repo should have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_EnsureBranch_Idempotent(t *testing.T) {
|
||||
srv := newFakeGiteaServer()
|
||||
srv.orgs["acme"] = true
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := NewClient(ts.URL, "test-token")
|
||||
if err := c.EnsureRepo(context.Background(), "acme", "site"); err != nil {
|
||||
t.Fatalf("ensure repo: %v", err)
|
||||
}
|
||||
// main exists from EnsureRepo's auto_init — short-circuit.
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", "main"); err != nil {
|
||||
t.Errorf("EnsureBranch main should be no-op: %v", err)
|
||||
}
|
||||
// develop doesn't exist — create.
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", "develop"); err != nil {
|
||||
t.Errorf("EnsureBranch develop: %v", err)
|
||||
}
|
||||
if !srv.branches["acme/site"]["develop"] {
|
||||
t.Error("develop branch should have been created")
|
||||
}
|
||||
// re-call — idempotent (probe sees branch, no POST).
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", "develop"); err != nil {
|
||||
t.Errorf("EnsureBranch develop (2nd call): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_IsNotFound(t *testing.T) {
|
||||
if IsNotFound(nil) {
|
||||
t.Error("nil should not be NotFound")
|
||||
}
|
||||
if IsNotFound(errors.New("plain")) {
|
||||
t.Error("plain error should not be NotFound")
|
||||
}
|
||||
he := &HTTPError{Status: 404}
|
||||
if !IsNotFound(he) {
|
||||
t.Error("HTTPError 404 should be NotFound")
|
||||
}
|
||||
if IsNotFound(&HTTPError{Status: 500}) {
|
||||
t.Error("HTTPError 500 should not be NotFound")
|
||||
}
|
||||
}
|
||||
@ -37,7 +37,7 @@ import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/blueprint/internal/controller"
|
||||
"github.com/openova-io/openova/core/controllers/blueprint/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -76,7 +76,7 @@ func run(ctx context.Context, log *slog.Logger) error {
|
||||
case giteaToken == "":
|
||||
log.Warn("CATALYST_GITEA_TOKEN is empty; mirror writes are DISABLED — controller will validate + update status only")
|
||||
default:
|
||||
giteaClient = gitea.NewClient(giteaURL, giteaToken)
|
||||
giteaClient = gitea.New(giteaURL, giteaToken)
|
||||
log.Info("Gitea mirror enabled", "url", giteaURL)
|
||||
}
|
||||
|
||||
|
||||
@ -57,7 +57,7 @@ import (
|
||||
"k8s.io/client-go/dynamic"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/blueprint/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/blueprint/internal/validate"
|
||||
)
|
||||
|
||||
@ -368,10 +368,12 @@ func (r *Reconciler) mirrorBlueprint(ctx context.Context, bp *unstructured.Unstr
|
||||
|
||||
switch visibility {
|
||||
case VisibilityListed:
|
||||
if err := r.cfg.Gitea.EnsureRepo(ctx, CatalogOrg, repo); err != nil {
|
||||
if _, err := r.cfg.Gitea.EnsureRepo(ctx, CatalogOrg, repo,
|
||||
"Catalyst Blueprint mirror — auto-managed by blueprint-controller. Do not edit manually.",
|
||||
false); err != nil {
|
||||
return fmt.Errorf("EnsureRepo: %w", err)
|
||||
}
|
||||
_, err := r.cfg.Gitea.PutFile(ctx, CatalogOrg, repo, "main", "blueprint.yaml",
|
||||
_, _, err := r.cfg.Gitea.PutFile(ctx, CatalogOrg, repo, "main", "blueprint.yaml",
|
||||
mirrorYAML, fmt.Sprintf("publish %s @ %s", repo, stringFromSpec(bp, "version")))
|
||||
return err
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/blueprint/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
)
|
||||
|
||||
// newScheme wires the Blueprint GVR into a runtime.Scheme so the fake
|
||||
@ -234,7 +234,7 @@ func makeReconciler(t *testing.T, items ...*unstructured.Unstructured) (*Reconci
|
||||
fc := newFakeGiteaCounter()
|
||||
srv := httptest.NewServer(fc.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
cli := gitea.NewClient(srv.URL, "test-token")
|
||||
cli := gitea.New(srv.URL, "test-token")
|
||||
cli.HTTP = srv.Client()
|
||||
r := New(Config{
|
||||
DynamicClient: dc,
|
||||
|
||||
@ -1,312 +0,0 @@
|
||||
// Package gitea — minimal HTTP client for the Sovereign-local Gitea
|
||||
// instance.
|
||||
//
|
||||
// This package is intentionally narrow: it exposes ONLY the operations
|
||||
// the blueprint-controller needs to maintain the catalog mirror in
|
||||
// the `catalog` Gitea Org per docs/NAMING-CONVENTION.md §11.2:
|
||||
//
|
||||
// - GetFile(org, repo, branch, path) read a file
|
||||
// - PutFile(org, repo, branch, path, content, msg) create-or-update
|
||||
// - DeleteFile(org, repo, branch, path, msg) delete a file
|
||||
// - EnsureRepo(org, repo) create-if-missing
|
||||
//
|
||||
// Why a separate package from the existing
|
||||
// `products/catalyst/bootstrap/api/internal/handler/sme_tenant_gitops.go`:
|
||||
//
|
||||
// That package shells out to `git` + `git push` over a clone of the
|
||||
// openova-public GitOps repo. It runs in a pod with a writable tmpfs
|
||||
// and its commit cadence (one tenant overlay per provisioning) tolerates
|
||||
// the latency of clone-and-push. The blueprint-controller, by contrast,
|
||||
// reconciles N Blueprint CRs per K8s watch event — the per-event work
|
||||
// must be a small set of HTTP API calls, not a clone-push cycle.
|
||||
//
|
||||
// The Gitea HTTP API (api/v1) is the canonical seam for HTTP-level
|
||||
// Gitea mutation; both Gitea's built-in web UI and Actions runner use
|
||||
// it. Authentication via a personal-access token in the Authorization
|
||||
// header.
|
||||
//
|
||||
// SLICES C1/C2 NOTE: When organization-controller (C1) or
|
||||
// environment-controller (C2) need an HTTP Gitea client for their
|
||||
// own Gitea-Org / repo creation flows, they should EXTEND this package
|
||||
// rather than write a parallel one. The Coordinator's seam map will be
|
||||
// updated to reflect this once C1/C2 land. For now, this package lives
|
||||
// under the blueprint-controller's tree because it ships with C3; the
|
||||
// Coordinator may move it to `core/internal/gitea/` in a follow-up
|
||||
// slice when C1/C2 also need it.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a thin wrapper around http.Client targeting a Gitea
|
||||
// instance's `/api/v1` surface.
|
||||
type Client struct {
|
||||
// BaseURL is the Gitea root, e.g. "https://gitea.hfmp.openova.io".
|
||||
BaseURL string
|
||||
|
||||
// Token is a personal-access token with `repo` + `write:repository`
|
||||
// scopes for the catalog Gitea Org. In production this is wired
|
||||
// from CATALYST_GITEA_TOKEN.
|
||||
Token string
|
||||
|
||||
// HTTP is the underlying client. Tests inject a httptest server.
|
||||
// Default: a 30s timeout client with retries on 5xx.
|
||||
HTTP *http.Client
|
||||
|
||||
// User-Agent emitted on every request. Defaults to
|
||||
// "openova-blueprint-controller/1.0".
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// NewClient returns a Client with sensible defaults.
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
UserAgent: "openova-blueprint-controller/1.0",
|
||||
}
|
||||
}
|
||||
|
||||
// FileResponse is the subset of Gitea's contents-API response we use.
|
||||
// Full schema:
|
||||
// https://docs.gitea.com/api#tag/repository/operation/repoGetContents
|
||||
type FileResponse struct {
|
||||
Path string `json:"path"`
|
||||
SHA string `json:"sha"`
|
||||
Content string `json:"content"` // base64-encoded
|
||||
Type string `json:"type"` // "file" | "dir" | "symlink" | "submodule"
|
||||
}
|
||||
|
||||
// commitFilePayload — body for create / update / delete file operations.
|
||||
type commitFilePayload struct {
|
||||
Message string `json:"message"`
|
||||
Content string `json:"content,omitempty"` // base64-encoded; omitempty for delete
|
||||
SHA string `json:"sha,omitempty"` // required for update + delete
|
||||
Branch string `json:"branch,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPError reports a non-2xx response from the Gitea API.
|
||||
type HTTPError struct {
|
||||
Method string
|
||||
URL string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
return fmt.Sprintf("gitea: %s %s: HTTP %d: %s", e.Method, e.URL, e.Status, e.Body)
|
||||
}
|
||||
|
||||
// IsNotFound reports whether err is a 404 response.
|
||||
func IsNotFound(err error) bool {
|
||||
var he *HTTPError
|
||||
if !errors.As(err, &he) {
|
||||
return false
|
||||
}
|
||||
return he.Status == http.StatusNotFound
|
||||
}
|
||||
|
||||
// do builds, sends, and decodes a Gitea API request. dst may be nil
|
||||
// when the caller doesn't care about the response body.
|
||||
func (c *Client) do(ctx context.Context, method, path string, body interface{}, dst interface{}) error {
|
||||
if c.BaseURL == "" {
|
||||
return errors.New("gitea: BaseURL is empty")
|
||||
}
|
||||
if c.Token == "" {
|
||||
return errors.New("gitea: Token is empty")
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: marshal body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
url := c.BaseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: %s %s: %w", method, url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return &HTTPError{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Status: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
}
|
||||
}
|
||||
if dst != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, dst); err != nil {
|
||||
return fmt.Errorf("gitea: decode response from %s: %w", url, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFile reads a file from a repo at the given branch. Returns
|
||||
// (*FileResponse, nil) on success, (nil, IsNotFound-able error) when
|
||||
// the file (or repo) doesn't exist, (nil, otherErr) on transport
|
||||
// failures.
|
||||
func (c *Client) GetFile(ctx context.Context, org, repo, branch, path string) (*FileResponse, error) {
|
||||
q := url.Values{}
|
||||
if branch != "" {
|
||||
q.Set("ref", branch)
|
||||
}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if qs := q.Encode(); qs != "" {
|
||||
endpoint += "?" + qs
|
||||
}
|
||||
out := &FileResponse{}
|
||||
if err := c.do(ctx, http.MethodGet, endpoint, nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PutFile creates or updates a file at path. If the file already
|
||||
// exists, the call performs an update by passing the current SHA.
|
||||
// Idempotent: if content matches the existing file byte-for-byte, no
|
||||
// API call is made (saves a write to Gitea + the etcd watch event).
|
||||
//
|
||||
// Returns the new SHA on success.
|
||||
func (c *Client) PutFile(ctx context.Context, org, repo, branch, path string, content []byte, message string) (string, error) {
|
||||
encoded := base64.StdEncoding.EncodeToString(content)
|
||||
|
||||
// Probe existing.
|
||||
existing, err := c.GetFile(ctx, org, repo, branch, path)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Decode existing content; skip the write if identical.
|
||||
decoded, decErr := base64.StdEncoding.DecodeString(strings.ReplaceAll(existing.Content, "\n", ""))
|
||||
if decErr == nil && bytes.Equal(decoded, content) {
|
||||
return existing.SHA, nil
|
||||
}
|
||||
// Update path.
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
Content: encoded,
|
||||
SHA: existing.SHA,
|
||||
Branch: branch,
|
||||
}
|
||||
out := &FileResponse{}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if err := c.do(ctx, http.MethodPut, endpoint, body, out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.SHA, nil
|
||||
case IsNotFound(err):
|
||||
// Create path.
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
Content: encoded,
|
||||
Branch: branch,
|
||||
}
|
||||
out := &FileResponse{}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if err := c.do(ctx, http.MethodPost, endpoint, body, out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.SHA, nil
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFile removes path from the repo at branch. Idempotent: a 404
|
||||
// from the probe returns (true, nil) — the file is already absent.
|
||||
//
|
||||
// Returns (deleted, err). deleted=true when the file existed and was
|
||||
// deleted (or was already absent); deleted=false only on transport
|
||||
// failures.
|
||||
func (c *Client) DeleteFile(ctx context.Context, org, repo, branch, path, message string) (bool, error) {
|
||||
existing, err := c.GetFile(ctx, org, repo, branch, path)
|
||||
switch {
|
||||
case IsNotFound(err):
|
||||
return true, nil
|
||||
case err != nil:
|
||||
return false, err
|
||||
}
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
SHA: existing.SHA,
|
||||
Branch: branch,
|
||||
}
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if err := c.do(ctx, http.MethodDelete, endpoint, body, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// EnsureRepo creates the repo if it doesn't exist. Idempotent.
|
||||
//
|
||||
// Per docs/NAMING-CONVENTION.md §11.2, the catalog Gitea Org holds one
|
||||
// repo per Blueprint at `<bp-name>`. The blueprint-controller pre-creates
|
||||
// these via this call before issuing the first PutFile.
|
||||
func (c *Client) EnsureRepo(ctx context.Context, org, repo string) error {
|
||||
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo))
|
||||
if err := c.do(ctx, http.MethodGet, endpoint, nil, nil); err == nil {
|
||||
return nil
|
||||
} else if !IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
// Create.
|
||||
createEndpoint := fmt.Sprintf("/api/v1/orgs/%s/repos", url.PathEscape(org))
|
||||
body := map[string]interface{}{
|
||||
"name": repo,
|
||||
"description": "Catalyst Blueprint mirror — auto-managed by blueprint-controller. Do not edit manually.",
|
||||
"private": false, // catalog Org per §11.2 is Sovereign-wide visible
|
||||
"auto_init": true, // ensures branch exists for first PutFile
|
||||
"default_branch": "main",
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, createEndpoint, body, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathEscapeSegments escapes each path segment but preserves slashes.
|
||||
// `url.PathEscape` would encode the slashes, breaking Gitea's path
|
||||
// resolution. We need per-segment escaping for cases where a path
|
||||
// component contains a space or '#' / '?'.
|
||||
func pathEscapeSegments(p string) string {
|
||||
parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
for i, s := range parts {
|
||||
parts[i] = url.PathEscape(s)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
@ -1,249 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGitea is a tiny in-memory Gitea-API stub. It supports only the
|
||||
// endpoints the Client uses + records request count per (method, path)
|
||||
// for assertion.
|
||||
type fakeGitea struct {
|
||||
mu sync.Mutex
|
||||
files map[string][]byte // key: org/repo/branch/path
|
||||
repos map[string]bool // key: org/repo
|
||||
calls map[string]int // key: METHOD path
|
||||
}
|
||||
|
||||
func newFakeGitea() *fakeGitea {
|
||||
return &fakeGitea{
|
||||
files: make(map[string][]byte),
|
||||
repos: make(map[string]bool),
|
||||
calls: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGitea) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
f.calls[r.Method+" "+r.URL.Path] = f.calls[r.Method+" "+r.URL.Path] + 1
|
||||
f.mu.Unlock()
|
||||
|
||||
// /api/v1/repos/<org>/<repo> GET (probe), POST (create from /orgs/.../repos)
|
||||
// /api/v1/orgs/<org>/repos POST
|
||||
// /api/v1/repos/<org>/<repo>/contents/<path...> GET/POST/PUT/DELETE
|
||||
path := r.URL.Path
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v1/orgs/") && strings.HasSuffix(path, "/repos") && r.Method == http.MethodPost:
|
||||
parts := strings.Split(path, "/")
|
||||
// /api/v1/orgs/<org>/repos -> parts: ["", "api", "v1", "orgs", <org>, "repos"]
|
||||
if len(parts) != 6 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
org := parts[4]
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
repo, _ := body["name"].(string)
|
||||
f.mu.Lock()
|
||||
f.repos[org+"/"+repo] = true
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"name": repo})
|
||||
return
|
||||
|
||||
case strings.HasPrefix(path, "/api/v1/repos/"):
|
||||
rest := strings.TrimPrefix(path, "/api/v1/repos/")
|
||||
segs := strings.SplitN(rest, "/", 4)
|
||||
if len(segs) < 2 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
org, repo := segs[0], segs[1]
|
||||
|
||||
// Probe: /api/v1/repos/<org>/<repo>
|
||||
if len(segs) == 2 {
|
||||
f.mu.Lock()
|
||||
exists := f.repos[org+"/"+repo]
|
||||
f.mu.Unlock()
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"name": repo})
|
||||
return
|
||||
}
|
||||
|
||||
// /api/v1/repos/<org>/<repo>/contents/<path...>
|
||||
if len(segs) == 4 && segs[2] == "contents" {
|
||||
p := segs[3]
|
||||
branch := r.URL.Query().Get("ref")
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
key := org + "/" + repo + "/" + branch + "/" + p
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
f.mu.Lock()
|
||||
content, ok := f.files[key]
|
||||
f.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(FileResponse{
|
||||
Path: p,
|
||||
SHA: "sha-" + key,
|
||||
Content: base64.StdEncoding.EncodeToString(content),
|
||||
Type: "file",
|
||||
})
|
||||
return
|
||||
case http.MethodPost, http.MethodPut:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var p commitFilePayload
|
||||
_ = json.Unmarshal(body, &p)
|
||||
decoded, _ := base64.StdEncoding.DecodeString(p.Content)
|
||||
f.mu.Lock()
|
||||
f.files[key] = decoded
|
||||
f.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(FileResponse{
|
||||
Path: segs[3], SHA: "sha-" + key, Content: p.Content, Type: "file",
|
||||
})
|
||||
return
|
||||
case http.MethodDelete:
|
||||
f.mu.Lock()
|
||||
delete(f.files, key)
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func newClientFor(srv *httptest.Server) *Client {
|
||||
c := NewClient(srv.URL, "test-token")
|
||||
c.HTTP = srv.Client()
|
||||
return c
|
||||
}
|
||||
|
||||
func TestEnsureRepo_CreateAndIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFakeGitea()
|
||||
srv := httptest.NewServer(fake.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClientFor(srv)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.EnsureRepo(ctx, "catalog", "bp-test"); err != nil {
|
||||
t.Fatalf("first EnsureRepo: %v", err)
|
||||
}
|
||||
if err := c.EnsureRepo(ctx, "catalog", "bp-test"); err != nil {
|
||||
t.Fatalf("second EnsureRepo (idempotent): %v", err)
|
||||
}
|
||||
// Probe count: 2 GETs, 1 POST.
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if got := fake.calls["GET /api/v1/repos/catalog/bp-test"]; got != 2 {
|
||||
t.Errorf("expected 2 GETs, got %d", got)
|
||||
}
|
||||
if got := fake.calls["POST /api/v1/orgs/catalog/repos"]; got != 1 {
|
||||
t.Errorf("expected 1 POST, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFile_CreateUpdateIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFakeGitea()
|
||||
srv := httptest.NewServer(fake.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClientFor(srv)
|
||||
ctx := context.Background()
|
||||
_ = c.EnsureRepo(ctx, "catalog", "bp-test")
|
||||
|
||||
// First PutFile creates.
|
||||
if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("v1\n"), "init"); err != nil {
|
||||
t.Fatalf("first PutFile: %v", err)
|
||||
}
|
||||
// Re-PutFile with identical content is a no-op (no PUT, only the
|
||||
// probe GET).
|
||||
fake.mu.Lock()
|
||||
beforePuts := fake.calls["PUT /api/v1/repos/catalog/bp-test/contents/blueprint.yaml"]
|
||||
fake.mu.Unlock()
|
||||
|
||||
if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("v1\n"), "noop"); err != nil {
|
||||
t.Fatalf("idempotent PutFile: %v", err)
|
||||
}
|
||||
|
||||
fake.mu.Lock()
|
||||
afterPuts := fake.calls["PUT /api/v1/repos/catalog/bp-test/contents/blueprint.yaml"]
|
||||
fake.mu.Unlock()
|
||||
if afterPuts != beforePuts {
|
||||
t.Errorf("idempotent PutFile triggered %d new PUTs", afterPuts-beforePuts)
|
||||
}
|
||||
|
||||
// PutFile with new content updates.
|
||||
if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("v2\n"), "bump"); err != nil {
|
||||
t.Fatalf("update PutFile: %v", err)
|
||||
}
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if got := fake.calls["PUT /api/v1/repos/catalog/bp-test/contents/blueprint.yaml"]; got != 1 {
|
||||
t.Errorf("expected 1 update PUT, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFile_PresentAndAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFakeGitea()
|
||||
srv := httptest.NewServer(fake.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClientFor(srv)
|
||||
ctx := context.Background()
|
||||
_ = c.EnsureRepo(ctx, "catalog", "bp-test")
|
||||
if _, err := c.PutFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", []byte("x"), "init"); err != nil {
|
||||
t.Fatalf("PutFile: %v", err)
|
||||
}
|
||||
|
||||
// Delete present file.
|
||||
deleted, err := c.DeleteFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", "withdraw")
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("DeleteFile: %v deleted=%v", err, deleted)
|
||||
}
|
||||
|
||||
// Delete already-absent file → idempotent.
|
||||
deleted, err = c.DeleteFile(ctx, "catalog", "bp-test", "main", "blueprint.yaml", "withdraw-again")
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("idempotent DeleteFile: %v deleted=%v", err, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsNotFound(nil) {
|
||||
t.Error("IsNotFound(nil) = true")
|
||||
}
|
||||
notFound := &HTTPError{Status: 404}
|
||||
if !IsNotFound(notFound) {
|
||||
t.Error("IsNotFound(404) = false")
|
||||
}
|
||||
other := &HTTPError{Status: 500}
|
||||
if IsNotFound(other) {
|
||||
t.Error("IsNotFound(500) = true")
|
||||
}
|
||||
}
|
||||
@ -28,7 +28,7 @@ import (
|
||||
|
||||
envv1 "github.com/openova-io/openova/core/controllers/environment/api/v1"
|
||||
"github.com/openova-io/openova/core/controllers/environment/internal/controller"
|
||||
"github.com/openova-io/openova/core/controllers/environment/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -74,8 +74,10 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
giteaClient := gitea.NewClient(
|
||||
getEnvDefault("GITEA_API_URL", "http://gitea-http.gitea.svc.cluster.local:3000/api/v1"),
|
||||
// CC2 SUPERSET client: BaseURL is the Gitea root WITHOUT /api/v1;
|
||||
// the client appends /api/v1 internally.
|
||||
giteaClient := gitea.New(
|
||||
getEnvDefault("GITEA_API_URL", "http://gitea-http.gitea.svc.cluster.local:3000"),
|
||||
os.Getenv("GITEA_TOKEN"),
|
||||
)
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
envv1 "github.com/openova-io/openova/core/controllers/environment/api/v1"
|
||||
"github.com/openova-io/openova/core/controllers/environment/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/environment/internal/gitops"
|
||||
)
|
||||
|
||||
@ -55,13 +55,14 @@ import (
|
||||
// inject a fake without spinning up a real Gitea server, AND keeps
|
||||
// the production gitea.Client free of test-only behavior.
|
||||
type GiteaClient interface {
|
||||
GetOrg(ctx context.Context, org string) (*gitea.Org, error)
|
||||
UpsertFile(
|
||||
GetOrg(ctx context.Context, org string) (gitea.Org, error)
|
||||
PutFile(
|
||||
ctx context.Context,
|
||||
org, repo, branch, path string,
|
||||
content []byte,
|
||||
message, authorName, authorEmail string,
|
||||
) (committed bool, err error)
|
||||
message string,
|
||||
opts ...gitea.PutFileOpts,
|
||||
) (file gitea.File, committed bool, err error)
|
||||
}
|
||||
|
||||
// Config holds the reconciler's runtime configuration. Per Inviolable
|
||||
@ -235,7 +236,7 @@ func (r *EnvironmentReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("environment-controller: reconcile %s on %s", envName, host)
|
||||
committed, err := r.Gitea.UpsertFile(
|
||||
_, committed, err := r.Gitea.PutFile(
|
||||
ctx,
|
||||
env.Spec.OrganizationRef,
|
||||
repo,
|
||||
@ -243,8 +244,10 @@ func (r *EnvironmentReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
path,
|
||||
manifest,
|
||||
message,
|
||||
cfg.CommitAuthorName,
|
||||
cfg.CommitAuthorEmail,
|
||||
gitea.PutFileOpts{
|
||||
AuthorName: cfg.CommitAuthorName,
|
||||
AuthorEmail: cfg.CommitAuthorEmail,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, gitea.ErrRepoNotFound) {
|
||||
|
||||
@ -17,11 +17,11 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
envv1 "github.com/openova-io/openova/core/controllers/environment/api/v1"
|
||||
"github.com/openova-io/openova/core/controllers/environment/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
)
|
||||
|
||||
// fakeGitea is a deterministic test double for the GiteaClient
|
||||
// interface. It records every UpsertFile call so tests can assert
|
||||
// interface. It records every PutFile call so tests can assert
|
||||
// idempotency, multi-region fan-out, and drift handling.
|
||||
type fakeGitea struct {
|
||||
orgs map[string]*gitea.Org
|
||||
@ -46,25 +46,26 @@ func newFakeGitea() *fakeGitea {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGitea) GetOrg(_ context.Context, org string) (*gitea.Org, error) {
|
||||
func (f *fakeGitea) GetOrg(_ context.Context, org string) (gitea.Org, error) {
|
||||
if err, ok := f.orgErrors[org]; ok {
|
||||
return nil, err
|
||||
return gitea.Org{}, err
|
||||
}
|
||||
if o, ok := f.orgs[org]; ok {
|
||||
return o, nil
|
||||
return *o, nil
|
||||
}
|
||||
return nil, gitea.ErrOrgNotFound
|
||||
return gitea.Org{}, gitea.ErrOrgNotFound
|
||||
}
|
||||
|
||||
func (f *fakeGitea) UpsertFile(
|
||||
func (f *fakeGitea) PutFile(
|
||||
_ context.Context,
|
||||
org, repo, branch, path string,
|
||||
content []byte,
|
||||
_, _, _ string,
|
||||
) (bool, error) {
|
||||
_ string,
|
||||
_ ...gitea.PutFileOpts,
|
||||
) (gitea.File, bool, error) {
|
||||
if f.upsertErrorPath != "" && path == f.upsertErrorPath {
|
||||
f.upsertCalls = append(f.upsertCalls, upsertCall{Org: org, Repo: repo, Branch: branch, Path: path, Content: content, Committed: false})
|
||||
return false, f.upsertError
|
||||
return gitea.File{}, false, f.upsertError
|
||||
}
|
||||
key := org + "|" + repo + "|" + branch + "|" + path
|
||||
committed := true
|
||||
@ -80,7 +81,7 @@ func (f *fakeGitea) UpsertFile(
|
||||
Content: append([]byte(nil), content...),
|
||||
Committed: committed,
|
||||
})
|
||||
return committed, nil
|
||||
return gitea.File{Path: path}, committed, nil
|
||||
}
|
||||
|
||||
func newScheme(t *testing.T) *runtime.Scheme {
|
||||
|
||||
@ -1,294 +0,0 @@
|
||||
// Package gitea is a minimal Gitea REST client used by
|
||||
// environment-controller (slice C2 of EPIC-0 #1095) to:
|
||||
//
|
||||
// 1. Verify the per-Org Gitea Org exists
|
||||
// (`GET /orgs/{org}` — Environments without an Organization parent
|
||||
// are surfaced as `GiteaOrgReady=False`, never panic).
|
||||
//
|
||||
// 2. Idempotently write per-vCluster Flux GitRepository manifests into
|
||||
// the Org's Gitea repo at
|
||||
// `clusters/<host-cluster>/environments/<env-name>/gitrepository.yaml`
|
||||
// (`GET/POST/PUT /repos/{org}/{repo}/contents/{path}` — base64
|
||||
// payload, BLOB SHA + branch parameters mirror the GitHub Git Data
|
||||
// API shape).
|
||||
//
|
||||
// Gitea exposes a GitHub-compatible REST API at `/api/v1`, so the same
|
||||
// path/parameter shape as the existing
|
||||
// `core/services/provisioning/github/client.go` works against
|
||||
// `gitea.<sovereign>:3000/api/v1`. We do NOT extend that client here —
|
||||
// it commits via the tree-and-ref Git Data API, which Gitea also
|
||||
// supports but is heavier than what this controller needs. Per-file
|
||||
// upsert via `/contents/` is the lightest idempotent path.
|
||||
//
|
||||
// Per docs/INVIOLABLE-PRINCIPLES.md #3 the controller writes manifests
|
||||
// to Gitea repos; Flux applies them. There is NO `kubectl apply`,
|
||||
// `helm install`, or `git` CLI exec in this package.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a minimal Gitea REST client. Construct via NewClient.
|
||||
type Client struct {
|
||||
BaseURL string // e.g. "https://gitea.hfmp.acme.openova.io/api/v1"
|
||||
Token string // Gitea personal/access token with org:read + repo:write
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns a Client with sensible defaults. baseURL must end
|
||||
// with `/api/v1` (Gitea's REST root). token may be empty for read-only
|
||||
// endpoints in development; the controller's reconcile path requires
|
||||
// repo:write and will fail with 401/403 surfaced as a Condition.
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
if baseURL == "" {
|
||||
baseURL = "http://gitea-http.gitea.svc.cluster.local:3000/api/v1"
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTP: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ErrOrgNotFound is returned by GetOrg when the named Org does not
|
||||
// exist. The controller maps this to a `GiteaOrgReady=False` Condition
|
||||
// rather than returning it from Reconcile (per slice brief: an
|
||||
// Environment whose Organization parent does not exist is invalid; it
|
||||
// surfaces a Condition, it does not crashloop).
|
||||
var ErrOrgNotFound = errors.New("gitea: org not found")
|
||||
|
||||
// ErrRepoNotFound is returned by GetFile when the Org/repo pair does
|
||||
// not exist. This is a transient signal during cold-start (Org exists
|
||||
// but the per-app repo has not been created by application-controller
|
||||
// yet). The reconciler logs and re-queues; it does NOT auto-create the
|
||||
// repo — that is application-controller (slice C4)'s responsibility.
|
||||
var ErrRepoNotFound = errors.New("gitea: repo not found")
|
||||
|
||||
// ErrFileNotFound is returned by GetFile when the path does not exist
|
||||
// on the named branch. This is the create-vs-update branching signal
|
||||
// for UpsertFile.
|
||||
var ErrFileNotFound = errors.New("gitea: file not found")
|
||||
|
||||
// Org represents the subset of `GET /orgs/{org}` we use.
|
||||
type Org struct {
|
||||
Username string `json:"username"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// GetOrg returns the Gitea Org by slug. Maps 404 → ErrOrgNotFound.
|
||||
func (c *Client) GetOrg(ctx context.Context, org string) (*Org, error) {
|
||||
if org == "" {
|
||||
return nil, errors.New("gitea: org slug must be non-empty")
|
||||
}
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/orgs/"+url.PathEscape(org), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: GET /orgs/%s: %w", org, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrOrgNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, statusError("GET /orgs", resp)
|
||||
}
|
||||
var out Org
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("gitea: decode org: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// FileContent represents the subset of `GET /repos/{org}/{repo}/contents/{path}`
|
||||
// we use. SHA is the BLOB sha (NOT a commit sha); it is required by
|
||||
// the PUT-update path so Gitea can refuse fast-forward conflicts.
|
||||
type FileContent struct {
|
||||
Path string `json:"path"`
|
||||
SHA string `json:"sha"`
|
||||
Content string `json:"content"` // base64-encoded
|
||||
}
|
||||
|
||||
// GetFile fetches the current content of a file on a given branch.
|
||||
// Returns ErrRepoNotFound or ErrFileNotFound for the two distinct 404
|
||||
// cases (the API returns the same 404 status; the body distinguishes).
|
||||
func (c *Client) GetFile(ctx context.Context, org, repo, branch, path string) (*FileContent, error) {
|
||||
if org == "" || repo == "" || branch == "" || path == "" {
|
||||
return nil, errors.New("gitea: GetFile requires non-empty org, repo, branch, path")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("ref", branch)
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/contents/%s?%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscape(path), q.Encode())
|
||||
req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: GET contents: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// Distinguish missing-repo vs missing-file by probing the
|
||||
// repo root: missing-repo bubbles up so the controller can
|
||||
// log a clearer message + re-queue.
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(strings.ToLower(string(body)), "repository") {
|
||||
return nil, ErrRepoNotFound
|
||||
}
|
||||
return nil, ErrFileNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, statusError("GET contents", resp)
|
||||
}
|
||||
var out FileContent
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("gitea: decode contents: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// UpsertFile idempotently creates-or-updates a file on `branch`.
|
||||
//
|
||||
// - If the file does not exist (ErrFileNotFound), POST with a fresh
|
||||
// create payload.
|
||||
// - If the file exists, compare bytes with the new content; if equal,
|
||||
// return without writing (idempotent re-reconcile path — no spurious
|
||||
// commits in the per-Org repo's history).
|
||||
// - Otherwise PUT with the existing blob SHA so Gitea can refuse
|
||||
// fast-forward conflicts (consistent with the GitHub Git Data API
|
||||
// contract used by `core/services/provisioning/github/client.go`).
|
||||
//
|
||||
// The commit author + message are mandatory per Gitea API. Returns
|
||||
// (committed=true) when the controller actually wrote bytes; false when
|
||||
// it short-circuited because content was unchanged.
|
||||
func (c *Client) UpsertFile(
|
||||
ctx context.Context,
|
||||
org, repo, branch, path string,
|
||||
content []byte,
|
||||
message, authorName, authorEmail string,
|
||||
) (committed bool, err error) {
|
||||
if message == "" || authorName == "" || authorEmail == "" {
|
||||
return false, errors.New("gitea: UpsertFile requires message + author")
|
||||
}
|
||||
existing, err := c.GetFile(ctx, org, repo, branch, path)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Decode existing content; if identical, short-circuit.
|
||||
decoded, decErr := base64.StdEncoding.DecodeString(existing.Content)
|
||||
if decErr == nil && bytes.Equal(decoded, content) {
|
||||
return false, nil
|
||||
}
|
||||
// Update.
|
||||
body := contentsBody{
|
||||
Branch: branch,
|
||||
Content: base64.StdEncoding.EncodeToString(content),
|
||||
Message: message,
|
||||
SHA: existing.SHA,
|
||||
Author: signature{Name: authorName, Email: authorEmail},
|
||||
Committer: signature{Name: authorName, Email: authorEmail},
|
||||
}
|
||||
return true, c.contentsCall(ctx, http.MethodPut, org, repo, path, body)
|
||||
case errors.Is(err, ErrFileNotFound):
|
||||
// Create.
|
||||
body := contentsBody{
|
||||
Branch: branch,
|
||||
Content: base64.StdEncoding.EncodeToString(content),
|
||||
Message: message,
|
||||
Author: signature{Name: authorName, Email: authorEmail},
|
||||
Committer: signature{Name: authorName, Email: authorEmail},
|
||||
}
|
||||
return true, c.contentsCall(ctx, http.MethodPost, org, repo, path, body)
|
||||
case errors.Is(err, ErrRepoNotFound):
|
||||
return false, ErrRepoNotFound
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
type signature struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type contentsBody struct {
|
||||
Branch string `json:"branch"`
|
||||
Content string `json:"content"`
|
||||
Message string `json:"message"`
|
||||
SHA string `json:"sha,omitempty"`
|
||||
Author signature `json:"author,omitempty"`
|
||||
Committer signature `json:"committer,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) contentsCall(ctx context.Context, method, org, repo, path string, body contentsBody) error {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscape(path))
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: marshal contents body: %w", err)
|
||||
}
|
||||
req, err := c.newRequest(ctx, method, endpoint, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: %s contents: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusCreated:
|
||||
return nil
|
||||
case http.StatusNotFound:
|
||||
return ErrRepoNotFound
|
||||
default:
|
||||
return statusError(method+" contents", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Request, error) {
|
||||
full := c.BaseURL + endpoint
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: build request: %w", err)
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.Token)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func statusError(op string, resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("gitea: %s: %s — %s", op, resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
// pathEscape escapes a path while preserving forward slashes (Gitea
|
||||
// treats the path as a directory tree; url.PathEscape would encode "/"
|
||||
// as "%2F" which would 404 the entire request).
|
||||
func pathEscape(p string) string {
|
||||
parts := strings.Split(p, "/")
|
||||
for i, seg := range parts {
|
||||
parts[i] = url.PathEscape(seg)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Verify GetOrg success.
|
||||
func TestGetOrg_OK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/orgs/acme", r.URL.Path)
|
||||
assert.Equal(t, "token test-tok", r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Org{Username: "acme", FullName: "ACME Corp"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL+"/api/v1", "test-tok")
|
||||
got, err := c.GetOrg(context.Background(), "acme")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "acme", got.Username)
|
||||
assert.Equal(t, "ACME Corp", got.FullName)
|
||||
}
|
||||
|
||||
// Verify GetOrg 404 maps to ErrOrgNotFound.
|
||||
func TestGetOrg_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL+"/api/v1", "tok")
|
||||
_, err := c.GetOrg(context.Background(), "ghost")
|
||||
assert.True(t, errors.Is(err, ErrOrgNotFound))
|
||||
}
|
||||
|
||||
// Verify GetOrg 500 surfaces a wrapped error (not ErrOrgNotFound).
|
||||
func TestGetOrg_ServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"message":"boom"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL+"/api/v1", "tok")
|
||||
_, err := c.GetOrg(context.Background(), "acme")
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, ErrOrgNotFound))
|
||||
assert.Contains(t, err.Error(), "500")
|
||||
}
|
||||
|
||||
// Verify the create path: when GetFile returns 404 file-only, UpsertFile POSTs.
|
||||
func TestUpsertFile_CreatePath(t *testing.T) {
|
||||
gotPost := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// File doesn't exist yet — return 404 with no "repository" hint.
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"file does not exist"}`)
|
||||
case http.MethodPost:
|
||||
gotPost = true
|
||||
var body contentsBody
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||
assert.Equal(t, "main", body.Branch)
|
||||
assert.Equal(t, "test commit", body.Message)
|
||||
decoded, err := base64.StdEncoding.DecodeString(body.Content)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello\n", string(decoded))
|
||||
assert.Empty(t, body.SHA, "create path must not include SHA")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"content":{}}`)
|
||||
default:
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL+"/api/v1", "tok")
|
||||
committed, err := c.UpsertFile(context.Background(),
|
||||
"acme", "acme-environment", "main", "clusters/x/y.yaml",
|
||||
[]byte("hello\n"), "test commit", "bot", "bot@test")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, committed)
|
||||
assert.True(t, gotPost, "must call POST when file is missing")
|
||||
}
|
||||
|
||||
// Verify the update path: GET returns existing content, identical bytes
|
||||
// short-circuit (no PUT); different bytes trigger PUT with SHA.
|
||||
func TestUpsertFile_IdempotentAndUpdate(t *testing.T) {
|
||||
state := []byte("hello\n")
|
||||
const sha = "abc123"
|
||||
getCount, putCount := 0, 0
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
getCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(FileContent{
|
||||
Path: "clusters/x/y.yaml",
|
||||
SHA: sha,
|
||||
Content: base64.StdEncoding.EncodeToString(state),
|
||||
})
|
||||
case http.MethodPut:
|
||||
putCount++
|
||||
var body contentsBody
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||
assert.Equal(t, sha, body.SHA, "update path must include the existing blob SHA")
|
||||
decoded, err := base64.StdEncoding.DecodeString(body.Content)
|
||||
require.NoError(t, err)
|
||||
state = decoded
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"content":{}}`)
|
||||
default:
|
||||
t.Fatalf("unexpected: %s", r.Method)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL+"/api/v1", "tok")
|
||||
|
||||
// 1st call: identical content → no commit.
|
||||
committed, err := c.UpsertFile(context.Background(),
|
||||
"acme", "acme-environment", "main", "clusters/x/y.yaml",
|
||||
[]byte("hello\n"), "noop", "bot", "bot@test")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, committed, "identical bytes must short-circuit")
|
||||
assert.Equal(t, 1, getCount)
|
||||
assert.Equal(t, 0, putCount, "no PUT when bytes match")
|
||||
|
||||
// 2nd call: different content → PUT with SHA.
|
||||
committed, err = c.UpsertFile(context.Background(),
|
||||
"acme", "acme-environment", "main", "clusters/x/y.yaml",
|
||||
[]byte("world\n"), "drift fix", "bot", "bot@test")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, committed)
|
||||
assert.Equal(t, 2, getCount)
|
||||
assert.Equal(t, 1, putCount)
|
||||
assert.Equal(t, "world\n", string(state))
|
||||
}
|
||||
|
||||
// Verify repo-level 404 surfaces ErrRepoNotFound (distinct from
|
||||
// ErrFileNotFound).
|
||||
func TestUpsertFile_RepoNotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"The target couldn't be found, repository missing."}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL+"/api/v1", "tok")
|
||||
_, err := c.UpsertFile(context.Background(),
|
||||
"acme", "missing-repo", "main", "x.yaml",
|
||||
[]byte("data"), "msg", "bot", "bot@test")
|
||||
assert.True(t, errors.Is(err, ErrRepoNotFound), "expected ErrRepoNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// Verify path escaping preserves slashes (Gitea expects directory tree).
|
||||
func TestPathEscape(t *testing.T) {
|
||||
assert.Equal(t, "clusters/hetzner-fsn-rtz-prod/environments/acme-prod/gitrepository.yaml",
|
||||
pathEscape("clusters/hetzner-fsn-rtz-prod/environments/acme-prod/gitrepository.yaml"))
|
||||
// Special chars in segments get escaped, slashes preserved.
|
||||
escaped := pathEscape("a b/c d/e.yaml")
|
||||
assert.True(t, strings.Contains(escaped, "/"), "slashes preserved")
|
||||
assert.True(t, strings.Contains(escaped, "%20") || strings.Contains(escaped, "+"),
|
||||
"spaces escaped within segments")
|
||||
}
|
||||
|
||||
// Verify required-arg validation.
|
||||
func TestUpsertFile_RequiresAllArgs(t *testing.T) {
|
||||
c := NewClient("http://nope/api/v1", "tok")
|
||||
_, err := c.UpsertFile(context.Background(), "acme", "r", "main", "x", []byte("data"), "", "bot", "bot@x")
|
||||
require.Error(t, err)
|
||||
_, err = c.UpsertFile(context.Background(), "acme", "r", "main", "x", []byte("data"), "msg", "", "bot@x")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Verify GetFile required-arg validation.
|
||||
func TestGetFile_RequiresAllArgs(t *testing.T) {
|
||||
c := NewClient("http://nope/api/v1", "tok")
|
||||
_, err := c.GetFile(context.Background(), "", "r", "main", "p")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Verify GetOrg empty slug rejected.
|
||||
func TestGetOrg_EmptySlug(t *testing.T) {
|
||||
c := NewClient("http://nope/api/v1", "tok")
|
||||
_, err := c.GetOrg(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
250
core/controllers/internal/gitea/DESIGN.md
Normal file
250
core/controllers/internal/gitea/DESIGN.md
Normal file
@ -0,0 +1,250 @@
|
||||
# CC2 — Unified Gitea Client SUPERSET Design
|
||||
|
||||
Slice CC2 of EPIC-0 (#1095) consolidates the four divergent Gitea HTTP
|
||||
clients shipped by the Group C controllers (slices C1-C4) into a single
|
||||
shared `core/controllers/internal/gitea` package.
|
||||
|
||||
CC1 (#1135) explicitly DEFERRED this consolidation because the four
|
||||
`Client` surfaces collide on signature shape and return semantics.
|
||||
CC2 designs the SUPERSET — the union of every operation any Group C
|
||||
controller currently uses — and migrates each controller to it without
|
||||
behavior change.
|
||||
|
||||
## 1. The four pre-existing surfaces (input)
|
||||
|
||||
| Method | C1 organization | C2 environment | C3 blueprint | C4 application |
|
||||
|---|---|---|---|---|
|
||||
| Org get | `GetOrg(slug) (Org, error)` | `GetOrg(org) (*Org, error)` | — | — |
|
||||
| Org create | `CreateOrg(slug, fullName, desc, vis) (Org, error)` | — | — | — |
|
||||
| Org find-or-create | `EnsureOrg(slug, fullName, desc, vis) (Org, error)` | — | — | — |
|
||||
| Repo get | `GetRepo(owner, name) (Repo, error)` | — | — | — |
|
||||
| Repo create | `CreateRepo(org, name, desc, private, autoInit, defaultBranch) (Repo, error)` | — | — | — |
|
||||
| Repo find-or-create | `EnsureRepo(org, name, desc, private) (Repo, error)` | — | `EnsureRepo(org, repo) error` | `EnsureRepo(org, repo) error` |
|
||||
| File get | `GetFile(owner, repo, path, branch) (FileContent, []byte, error)` | `GetFile(org, repo, branch, path) (*FileContent, error)` | `GetFile(org, repo, branch, path) (*FileResponse, error)` | `GetFile(org, repo, branch, path) (*FileResponse, error)` |
|
||||
| File create-or-update | `PutFile(owner, repo, path, branch, data, msg) (FileContent, error)` | `UpsertFile(org, repo, branch, path, data, msg, name, email) (committed bool, error)` | `PutFile(org, repo, branch, path, data, msg) (sha string, error)` | `PutFile(org, repo, branch, path, data, msg) (sha string, committed bool, error)` |
|
||||
| File delete | — | — | `DeleteFile(org, repo, branch, path, msg) (bool, error)` | `DeleteFile(org, repo, branch, path, msg) (bool, error)` |
|
||||
| Branch find-or-create | — | — | — | `EnsureBranch(org, repo, branch) error` |
|
||||
|
||||
### Pain points
|
||||
|
||||
1. **`EnsureRepo` collides on signature.** C1 returns `Repo` + accepts
|
||||
description/private; C3+C4 return `error` and accept just `(org, repo)`.
|
||||
2. **`PutFile` argument order varies** — C1 has `(path, branch, ...)`
|
||||
while C2/C3/C4 have `(branch, path, ...)`.
|
||||
3. **`PutFile` returns differ** — C1 returns `FileContent`, C3 returns
|
||||
`string`, C4 returns `(string, bool, error)`, C2 calls it `UpsertFile`
|
||||
and returns `(bool, error)`.
|
||||
4. **`GetFile` returns differ** — C1 returns `(FileContent, []byte, error)`
|
||||
(decoded blob); the others return base64-encoded.
|
||||
5. **`BaseURL` semantics differ** — env client expects URL ending in
|
||||
`/api/v1`; the others hardcode `/api/v1` in endpoint paths.
|
||||
6. **Error sentinels differ** — C2/C3/C4 use `HTTPError` + `IsNotFound`;
|
||||
C1 uses typed sentinel errors (`ErrOrgNotFound`, `ErrRepoNotFound`,
|
||||
`ErrFileNotFound`).
|
||||
|
||||
## 2. The SUPERSET surface (output)
|
||||
|
||||
The shared `core/controllers/internal/gitea/Client` exposes the
|
||||
**union** of every operation currently in use. Naming separates Org/Repo
|
||||
CRUD from File CRUD so call sites read obviously.
|
||||
|
||||
```go
|
||||
// Client wraps the Gitea Admin REST API.
|
||||
type Client struct {
|
||||
BaseURL string // e.g. "https://gitea.hfmp.openova.io" — /api/v1 is appended internally
|
||||
Token string // admin personal-access token
|
||||
HTTP *http.Client // tests inject httptest server.Client()
|
||||
UserAgent string // emitted on every request
|
||||
}
|
||||
|
||||
func New(baseURL, token string) *Client // 30s timeout default
|
||||
func NewWithHTTP(baseURL, token string, hc *http.Client) *Client
|
||||
|
||||
// --- Org + Repo CRUD ------------------------------------------------
|
||||
|
||||
type Org struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrg(ctx context.Context, slug string) (Org, error)
|
||||
func (c *Client) CreateOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error)
|
||||
func (c *Client) EnsureOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error)
|
||||
|
||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (Repo, error)
|
||||
func (c *Client) CreateRepo(ctx context.Context, org, name, description string, private, autoInit bool, defaultBranch string) (Repo, error)
|
||||
// EnsureRepo: SUPERSET of C1 + C3+C4 — returns the Repo for callers
|
||||
// that want it; auto_init=true and private flag are configurable to
|
||||
// support both catalog (public + auto-init) and per-Org (private +
|
||||
// auto-init) repo shapes. C3+C4 callers discard the Repo via `_`.
|
||||
func (c *Client) EnsureRepo(ctx context.Context, org, name, description string, private bool) (Repo, error)
|
||||
|
||||
// EnsureBranch: branches off main if absent. Idempotent on both 409
|
||||
// and 422.
|
||||
func (c *Client) EnsureBranch(ctx context.Context, org, repo, branch string) error
|
||||
|
||||
// --- File CRUD -----------------------------------------------------
|
||||
|
||||
// File is the surface returned by GetFile / PutFile. SHA is the BLOB
|
||||
// SHA needed by PUT-update; ContentBase64 is preserved to support
|
||||
// callers that want the raw response body.
|
||||
type File struct {
|
||||
Path string `json:"path"`
|
||||
SHA string `json:"sha"`
|
||||
ContentBase64 string `json:"content"` // base64 from Gitea
|
||||
Type string `json:"type"` // "file" | "dir" | "symlink" | "submodule"
|
||||
}
|
||||
|
||||
// Decoded returns the file's raw bytes (decoding ContentBase64).
|
||||
func (f *File) Decoded() ([]byte, error)
|
||||
|
||||
// GetFile fetches the file. Returns ErrFileNotFound on 404.
|
||||
// Returns ErrRepoNotFound when the 404 response body indicates the
|
||||
// repo itself is missing (C2 needs this distinction).
|
||||
func (c *Client) GetFile(ctx context.Context, org, repo, branch, path string) (File, error)
|
||||
|
||||
// PutFile creates the file if absent, updates it if present, OR
|
||||
// short-circuits with no API write if the existing content is
|
||||
// byte-equal to `data` (the canonical idempotency anchor — C1's
|
||||
// pattern; preserved in CC2). `committed` is true only when the
|
||||
// controller actually wrote bytes.
|
||||
//
|
||||
// The author/email parameters are optional — pass empty strings to
|
||||
// use Gitea's default committer (the token's owner). C2 passes them
|
||||
// explicitly for committer-attribution; C1/C3/C4 don't care.
|
||||
type PutFileOpts struct {
|
||||
AuthorName string
|
||||
AuthorEmail string
|
||||
}
|
||||
func (c *Client) PutFile(ctx context.Context, org, repo, branch, path string, data []byte, message string, opts ...PutFileOpts) (file File, committed bool, err error)
|
||||
|
||||
// DeleteFile removes the file. Idempotent: a 404 from the probe is
|
||||
// treated as "already absent" — returns (true, nil).
|
||||
func (c *Client) DeleteFile(ctx context.Context, org, repo, branch, path, message string) (deleted bool, err error)
|
||||
|
||||
// --- Errors --------------------------------------------------------
|
||||
|
||||
var (
|
||||
ErrOrgNotFound = errors.New("gitea: org not found")
|
||||
ErrRepoNotFound = errors.New("gitea: repo not found")
|
||||
ErrFileNotFound = errors.New("gitea: file not found")
|
||||
)
|
||||
|
||||
// HTTPError is returned for non-2xx responses that don't map to a
|
||||
// typed sentinel. Callers can inspect Status for retry decisions.
|
||||
type HTTPError struct {
|
||||
Method, URL string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
func (e *HTTPError) Error() string
|
||||
|
||||
// IsNotFound reports whether err is any of the 404-derived sentinels
|
||||
// (ErrOrgNotFound, ErrRepoNotFound, ErrFileNotFound) OR a HTTPError
|
||||
// with Status==404. Preserved for the C4 GiteaErrorClassifier surface.
|
||||
func IsNotFound(err error) bool
|
||||
|
||||
// IsConflict reports whether err is a 409. Preserved for C4
|
||||
// EnsureRepo's parallel-create race handling.
|
||||
func IsConflict(err error) bool
|
||||
```
|
||||
|
||||
### Method-by-method winner-pick rationale
|
||||
|
||||
| Method | Winner | Rationale |
|
||||
|---|---|---|
|
||||
| `GetOrg` | C1 (organization) | Has the cleanest typed-sentinel error path; C2's `*Org` was changed to value-type to match. |
|
||||
| `CreateOrg` | C1 (organization) | Only C1 implements it. Defaults visibility to "private" preserved. |
|
||||
| `EnsureOrg` | C1 (organization) | Only C1 implements it. 422/409 race re-find pattern preserved. |
|
||||
| `GetRepo` | C1 (organization) | Only C1 implements it directly (C3+C4 use raw `do(GET /repos/{org}/{name})`). |
|
||||
| `CreateRepo` | C1 (organization) | Only C1 has full surface (description/private/autoInit/defaultBranch). |
|
||||
| `EnsureRepo` | **C1 surface, C4 race-handling** | C1's signature `(org, name, desc, private) (Repo, error)` is the SUPERSET (C3+C4 callers can pass desc + private and discard Repo via `_`). C4's `IsConflict` + `IsNotFound` race handling under create folded in (the parallel-create race on a hot-reconcile loop is real). |
|
||||
| `EnsureBranch` | C4 (application) | Only C4 implements it. Probe-then-create with 409/422 idempotency preserved. |
|
||||
| `GetFile` | C2 (environment) | C2 has the repo-vs-file 404 distinction (probe body for "repository") — needed by env-controller. C1's pre-decoded `[]byte` second return is replaced by `File.Decoded()` helper to keep the signature uniform. |
|
||||
| `PutFile` | **C4 (application) signature, C1 byte-equal short-circuit** | C4's `(file, committed, err)` triple is the most informative; the byte-equal short-circuit (returns `committed=false` with no API write) is the canonical idempotency anchor present in all four. The optional `PutFileOpts` extends to support C2's committer attribution without polluting the common path. |
|
||||
| `DeleteFile` | C3/C4 (identical) | Idempotent-on-404 pattern, `(deleted, err)` return. |
|
||||
| `IsNotFound` / `IsConflict` | C4 (application) | The `HTTPError`-based helpers translate to the unified type. Extended to also recognize `ErrOrgNotFound`/`ErrRepoNotFound`/`ErrFileNotFound` so call sites don't care which form the client returned. |
|
||||
|
||||
### URL handling
|
||||
|
||||
The `BaseURL` parameter is the Gitea root **without** `/api/v1`. The
|
||||
client prepends `/api/v1` to every endpoint internally. environment-
|
||||
controller's `cmd/main.go` is updated to drop the trailing `/api/v1`
|
||||
from `GITEA_API_URL`.
|
||||
|
||||
## 3. Per-controller migration
|
||||
|
||||
### organization (C1)
|
||||
- Imports updated: `internal/gitea` → `internal/gitea` (shared)
|
||||
- `EnsureOrg`, `EnsureRepo` — same surface, no call-site change.
|
||||
- `PutFile(ctx, org, repo, path, branch, data, msg)` → `PutFile(ctx, org, repo, branch, path, data, msg)` — argument order swap (C1 was the outlier).
|
||||
- C1 ignored the return of `PutFile` (used `_, err :=`); now it captures `(_, _, err :=)` for the (file, committed, err) triple — committed bool is discarded.
|
||||
- `gitea.New(url, token)` constructor preserved.
|
||||
|
||||
### blueprint (C3)
|
||||
- Imports updated.
|
||||
- `EnsureRepo(ctx, org, repo)` → `_, err := EnsureRepo(ctx, org, repo, "Catalyst Blueprint mirror — auto-managed by blueprint-controller. Do not edit manually.", false)` — wrap with the existing description literal that was inlined in the old client; private=false (catalog Org per NAMING §11.2).
|
||||
- `PutFile(ctx, org, repo, branch, path, ...)` → `_, _, err := PutFile(ctx, ...)` — discard file + committed.
|
||||
- `DeleteFile` — same.
|
||||
- `gitea.NewClient(url, token)` → `gitea.New(url, token)`.
|
||||
|
||||
### environment (C2)
|
||||
- Imports updated.
|
||||
- `GetOrg(ctx, org) (*Org, error)` → `Org, error` (value type). Caller used `org.Username` — uses `org.Username` on value.
|
||||
- `UpsertFile(ctx, org, repo, branch, path, data, msg, authorName, authorEmail) (committed bool, error)` → `_, committed, err := PutFile(ctx, org, repo, branch, path, data, msg, gitea.PutFileOpts{AuthorName: authorName, AuthorEmail: authorEmail})` — preserves committer attribution via opts.
|
||||
- The `GiteaClient` interface in the controller updates to match the new surface.
|
||||
- `gitea.NewClient(url, token)` → `gitea.New(url, token)`. cmd/main.go drops trailing `/api/v1` from GITEA_API_URL.
|
||||
|
||||
### application (C4)
|
||||
- Imports updated.
|
||||
- `EnsureRepo(ctx, org, repo)` → `_, err := EnsureRepo(ctx, org, repo, "Application manifests — auto-managed by application-controller. Do not edit manually.", true)` — preserves the existing description + private=true.
|
||||
- `PutFile`, `DeleteFile`, `EnsureBranch` — same shape.
|
||||
- `gitea.NewClient(url, token)` → `gitea.New(url, token)`.
|
||||
- The `Gitea` interface in the controller updates to match the new method shape.
|
||||
|
||||
## 4. Tests preserved
|
||||
|
||||
httptest-based fakes follow the union of patterns from C1 (`gs.handle`),
|
||||
C3 (`fakeGitea.handler`), and C4 (`fakeGiteaServer.handler`). The
|
||||
new shared package's `client_test.go` covers:
|
||||
|
||||
- `TestEnsureOrg_FindHits` — find-or-create when org pre-exists (1 GET, 0 POST)
|
||||
- `TestEnsureOrg_CreatesWhenMissing` — 404 → POST
|
||||
- `TestEnsureOrg_409Race` — 422 on POST → re-find returns existing
|
||||
- `TestEnsureRepo_FindHits` — find-or-create when repo pre-exists
|
||||
- `TestEnsureRepo_CreatesWithPrivate` — POST with private=true
|
||||
- `TestEnsureRepo_OrgMissing` — 404 on POST → ErrOrgNotFound
|
||||
- `TestEnsureRepo_409Race` — 409 on POST → success (parallel create won)
|
||||
- `TestEnsureBranch_Main` — main branch is no-op
|
||||
- `TestEnsureBranch_CreatesDevelop` — branches from main
|
||||
- `TestEnsureBranch_Idempotent` — 422 already-exists
|
||||
- `TestGetFile_OK` — 200 returns File with base64 + Decoded() helper
|
||||
- `TestGetFile_FileNotFound` — 404 → ErrFileNotFound
|
||||
- `TestGetFile_RepoNotFound` — 404 with "repository" body → ErrRepoNotFound
|
||||
- `TestPutFile_CreateNew` — POST with no SHA
|
||||
- `TestPutFile_UpdateExisting` — PUT with existing SHA
|
||||
- `TestPutFile_ByteEqualNoOp` — identical content → 0 writes
|
||||
- `TestPutFile_WithAuthor` — opts pass committer through
|
||||
- `TestDeleteFile_Present` — 404 → idempotent
|
||||
- `TestDeleteFile_Absent` — already absent → idempotent
|
||||
- `TestIsNotFound` — recognizes typed sentinels + HTTPError 404
|
||||
- `TestIsConflict` — recognizes HTTPError 409
|
||||
|
||||
## 5. What CC2 explicitly does NOT do
|
||||
|
||||
- No new Gitea API methods beyond the union of existing usage.
|
||||
- No deploy-manifest changes.
|
||||
- No dep-version bumps.
|
||||
- No behavior change for any existing call site.
|
||||
653
core/controllers/internal/gitea/client.go
Normal file
653
core/controllers/internal/gitea/client.go
Normal file
@ -0,0 +1,653 @@
|
||||
// Package gitea is the SUPERSET Gitea HTTP client used by every Group
|
||||
// C controller. It consolidates the four divergent per-controller
|
||||
// clients (slices C1-C4) into a single shared surface promoted to
|
||||
// `core/controllers/internal/gitea/` by slice CC2 (#1095) per
|
||||
// `02-implementer-canon.md` §1+§2.
|
||||
//
|
||||
// The surface is the UNION of every operation any Group C controller
|
||||
// already used:
|
||||
//
|
||||
// - Org + Repo CRUD (was C1's surface):
|
||||
// GetOrg / CreateOrg / EnsureOrg / GetRepo / CreateRepo / EnsureRepo
|
||||
// - File CRUD (was C2/C3/C4's surface):
|
||||
// GetFile / PutFile / DeleteFile
|
||||
// - Branch CRUD (was C4's surface):
|
||||
// EnsureBranch
|
||||
//
|
||||
// CC2 deliberately preserves the canonical idempotency anchors:
|
||||
//
|
||||
// - EnsureOrg / EnsureRepo: find-or-create, with 422/409 race-re-find.
|
||||
// - PutFile: byte-equal short-circuit (no API write when existing
|
||||
// content is byte-equal to the requested content). This is the
|
||||
// mechanism by which controller-runtime's resync loop produces
|
||||
// zero Gitea writes on a steady-state Application/Environment/etc.
|
||||
// - DeleteFile: 404-on-probe is treated as "already deleted".
|
||||
// - EnsureBranch: 409/422 on create is treated as "raced; success".
|
||||
//
|
||||
// Endpoints (Gitea Admin REST API, version 1.22):
|
||||
//
|
||||
// GET /api/v1/orgs/{org}
|
||||
// POST /api/v1/admin/orgs
|
||||
// GET /api/v1/repos/{owner}/{repo}
|
||||
// POST /api/v1/orgs/{org}/repos
|
||||
// GET /api/v1/repos/{owner}/{repo}/branches/{branch}
|
||||
// POST /api/v1/repos/{owner}/{repo}/branches
|
||||
// GET /api/v1/repos/{owner}/{repo}/contents/{path}
|
||||
// POST /api/v1/repos/{owner}/{repo}/contents/{path}
|
||||
// PUT /api/v1/repos/{owner}/{repo}/contents/{path}
|
||||
// DELETE /api/v1/repos/{owner}/{repo}/contents/{path}
|
||||
//
|
||||
// Authentication: a static admin access-token (the catalyst-api
|
||||
// service-account token managed by Sovereign-admin) — passed via
|
||||
// `Authorization: token <hex>` header per Gitea convention.
|
||||
//
|
||||
// BaseURL semantics: the constructor takes the Gitea root WITHOUT
|
||||
// `/api/v1`. The client prepends `/api/v1` to every endpoint.
|
||||
// Migrating callers that previously stamped `/api/v1` into BaseURL
|
||||
// must drop that suffix.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
// ErrOrgNotFound is returned by GetOrg when the slug does not resolve
|
||||
// AND by EnsureRepo when the parent Org doesn't exist on the Gitea
|
||||
// instance.
|
||||
var ErrOrgNotFound = errors.New("gitea: org not found")
|
||||
|
||||
// ErrRepoNotFound is returned by GetRepo / GetFile when the repo does
|
||||
// not resolve. GetFile distinguishes this from ErrFileNotFound by
|
||||
// inspecting the 404 body for the substring "repository".
|
||||
var ErrRepoNotFound = errors.New("gitea: repo not found")
|
||||
|
||||
// ErrFileNotFound is returned by GetFile when the path does not exist
|
||||
// on the named branch (but the repo does).
|
||||
var ErrFileNotFound = errors.New("gitea: file not found")
|
||||
|
||||
// errAlreadyExists is the internal sentinel for the create-422/409
|
||||
// race path. Mapped to typed sentinels at the call boundary.
|
||||
var errAlreadyExists = errors.New("gitea: already exists")
|
||||
|
||||
// HTTPError reports a non-2xx response from the Gitea API that didn't
|
||||
// map to a typed sentinel. Callers can inspect Status for retry
|
||||
// decisions.
|
||||
type HTTPError struct {
|
||||
Method string
|
||||
URL string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
return fmt.Sprintf("gitea: %s %s: HTTP %d: %s", e.Method, e.URL, e.Status, e.Body)
|
||||
}
|
||||
|
||||
// IsNotFound reports whether err signals a 404 — either via a typed
|
||||
// sentinel (ErrOrgNotFound / ErrRepoNotFound / ErrFileNotFound) or via
|
||||
// a HTTPError with Status==404.
|
||||
func IsNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, ErrOrgNotFound) || errors.Is(err, ErrRepoNotFound) || errors.Is(err, ErrFileNotFound) {
|
||||
return true
|
||||
}
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) {
|
||||
return he.Status == http.StatusNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsConflict reports whether err is a 409. Used by EnsureRepo /
|
||||
// EnsureBranch to absorb parallel-create races.
|
||||
func IsConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) {
|
||||
return he.Status == http.StatusConflict
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Client
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
// Client wraps the Gitea Admin REST API.
|
||||
type Client struct {
|
||||
// BaseURL is the Gitea root WITHOUT `/api/v1` (e.g.
|
||||
// "https://gitea.hfmp.openova.io" or
|
||||
// "http://gitea-http.gitea.svc.cluster.local:3000"). The client
|
||||
// prepends `/api/v1` to every endpoint internally.
|
||||
BaseURL string
|
||||
|
||||
// Token is the personal-access token. Sent as `Authorization:
|
||||
// token <token>`.
|
||||
Token string
|
||||
|
||||
// HTTP is the underlying HTTP client. Tests inject httptest.Server's
|
||||
// Client(). Default: 30s timeout.
|
||||
HTTP *http.Client
|
||||
|
||||
// UserAgent is emitted on every request. Default: "openova-controllers/1.0".
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// New returns a Client with a 30s default timeout.
|
||||
func New(baseURL, token string) *Client {
|
||||
return NewWithHTTP(baseURL, token, &http.Client{Timeout: 30 * time.Second})
|
||||
}
|
||||
|
||||
// NewWithHTTP returns a Client using the supplied http.Client.
|
||||
func NewWithHTTP(baseURL, token string, hc *http.Client) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTP: hc,
|
||||
UserAgent: "openova-controllers/1.0",
|
||||
}
|
||||
}
|
||||
|
||||
// auth sets the Authorization + Accept headers on the request.
|
||||
func (c *Client) auth(r *http.Request) {
|
||||
if c.Token != "" {
|
||||
r.Header.Set("Authorization", "token "+c.Token)
|
||||
}
|
||||
r.Header.Set("Accept", "application/json")
|
||||
if c.UserAgent != "" {
|
||||
r.Header.Set("User-Agent", c.UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// do builds, sends, and decodes a Gitea API request.
|
||||
//
|
||||
// `endpoint` is appended to BaseURL+/api/v1; pass paths starting with
|
||||
// '/' (e.g. "/orgs/foo"). dst may be nil when the caller doesn't need
|
||||
// the response body.
|
||||
//
|
||||
// Returns nil on 2xx (decoding into dst when non-nil), or *HTTPError
|
||||
// for any non-2xx.
|
||||
func (c *Client) do(ctx context.Context, method, endpoint string, body interface{}, dst interface{}) (int, []byte, error) {
|
||||
if c.BaseURL == "" {
|
||||
return 0, nil, errors.New("gitea: BaseURL is empty")
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("gitea: marshal body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
fullURL := c.BaseURL + "/api/v1" + endpoint
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("gitea: build request: %w", err)
|
||||
}
|
||||
c.auth(req)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("gitea: %s %s: %w", method, fullURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, respBody, &HTTPError{
|
||||
Method: method,
|
||||
URL: fullURL,
|
||||
Status: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
}
|
||||
}
|
||||
if dst != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, dst); err != nil {
|
||||
return resp.StatusCode, respBody, fmt.Errorf("gitea: decode response from %s: %w", fullURL, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, respBody, nil
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Org + Repo CRUD
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
// Org is the slice of Gitea Organization fields the controllers use.
|
||||
type Org struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// adminOrgCreate is the payload for POST /admin/orgs.
|
||||
type adminOrgCreate struct {
|
||||
Username string `json:"username"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// Repo is the slice of Gitea Repository fields the controllers use.
|
||||
type Repo struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
}
|
||||
|
||||
// repoCreate is the request shape for POST /orgs/{org}/repos.
|
||||
type repoCreate struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private"`
|
||||
AutoInit bool `json:"auto_init"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
}
|
||||
|
||||
// GetOrg fetches the Gitea Org by slug. Returns ErrOrgNotFound on 404.
|
||||
func (c *Client) GetOrg(ctx context.Context, slug string) (Org, error) {
|
||||
if slug == "" {
|
||||
return Org{}, errors.New("gitea: org slug must be non-empty")
|
||||
}
|
||||
var out Org
|
||||
status, _, err := c.do(ctx, http.MethodGet, "/orgs/"+url.PathEscape(slug), nil, &out)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
return Org{}, ErrOrgNotFound
|
||||
}
|
||||
return Org{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateOrg creates a Gitea Org via the admin endpoint. Returns
|
||||
// errAlreadyExists (internal sentinel) on 422/409 so EnsureOrg can
|
||||
// re-find idempotently.
|
||||
func (c *Client) CreateOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) {
|
||||
if visibility == "" {
|
||||
visibility = "private"
|
||||
}
|
||||
body := adminOrgCreate{
|
||||
Username: slug,
|
||||
FullName: fullName,
|
||||
Description: description,
|
||||
Visibility: visibility,
|
||||
}
|
||||
var out Org
|
||||
status, _, err := c.do(ctx, http.MethodPost, "/admin/orgs", body, &out)
|
||||
if err != nil {
|
||||
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
|
||||
return Org{}, errAlreadyExists
|
||||
}
|
||||
return Org{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnsureOrg is the find-or-create shorthand. An existing Org's metadata
|
||||
// is NOT mutated to match the desired fullName/description; the
|
||||
// controller treats those as soft-attributes (operators rename via the
|
||||
// Gitea UI without conflict).
|
||||
func (c *Client) EnsureOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) {
|
||||
existing, err := c.GetOrg(ctx, slug)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, ErrOrgNotFound) {
|
||||
return Org{}, fmt.Errorf("gitea.EnsureOrg: get: %w", err)
|
||||
}
|
||||
created, err := c.CreateOrg(ctx, slug, fullName, description, visibility)
|
||||
if errors.Is(err, errAlreadyExists) {
|
||||
o, ferr := c.GetOrg(ctx, slug)
|
||||
if ferr != nil {
|
||||
return Org{}, fmt.Errorf("gitea.EnsureOrg: re-find after 422/409: %w", ferr)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Org{}, fmt.Errorf("gitea.EnsureOrg: create: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// GetRepo fetches the Gitea repo by owner/name. Returns ErrRepoNotFound on 404.
|
||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (Repo, error) {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s", url.PathEscape(owner), url.PathEscape(name))
|
||||
var out Repo
|
||||
status, _, err := c.do(ctx, http.MethodGet, endpoint, nil, &out)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
return Repo{}, ErrRepoNotFound
|
||||
}
|
||||
return Repo{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateRepo creates a repo under the given Org. Returns ErrOrgNotFound
|
||||
// when the parent Org doesn't exist, or errAlreadyExists on 422/409.
|
||||
func (c *Client) CreateRepo(ctx context.Context, org, name, description string, private, autoInit bool, defaultBranch string) (Repo, error) {
|
||||
if defaultBranch == "" {
|
||||
defaultBranch = "main"
|
||||
}
|
||||
body := repoCreate{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Private: private,
|
||||
AutoInit: autoInit,
|
||||
DefaultBranch: defaultBranch,
|
||||
}
|
||||
endpoint := fmt.Sprintf("/orgs/%s/repos", url.PathEscape(org))
|
||||
var out Repo
|
||||
status, _, err := c.do(ctx, http.MethodPost, endpoint, body, &out)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
return Repo{}, ErrOrgNotFound
|
||||
}
|
||||
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
|
||||
return Repo{}, errAlreadyExists
|
||||
}
|
||||
return Repo{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnsureRepo is the find-or-create shorthand. SUPERSET signature
|
||||
// (description + private flag) — C3+C4 callers pass their canonical
|
||||
// description literals + private flag; C1 already used this shape.
|
||||
//
|
||||
// auto_init=true is hardcoded so the default branch exists for the
|
||||
// first PutFile. The default branch is "main".
|
||||
//
|
||||
// On a parallel-create race (409 on POST or "already exists" 422),
|
||||
// re-finds the repo and returns it.
|
||||
func (c *Client) EnsureRepo(ctx context.Context, org, name, description string, private bool) (Repo, error) {
|
||||
existing, err := c.GetRepo(ctx, org, name)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, ErrRepoNotFound) {
|
||||
return Repo{}, fmt.Errorf("gitea.EnsureRepo: get: %w", err)
|
||||
}
|
||||
created, err := c.CreateRepo(ctx, org, name, description, private, true, "main")
|
||||
if errors.Is(err, errAlreadyExists) {
|
||||
r, ferr := c.GetRepo(ctx, org, name)
|
||||
if ferr != nil {
|
||||
return Repo{}, fmt.Errorf("gitea.EnsureRepo: re-find after 422/409: %w", ferr)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
if errors.Is(err, ErrOrgNotFound) {
|
||||
return Repo{}, ErrOrgNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Repo{}, fmt.Errorf("gitea.EnsureRepo: create: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// EnsureBranch ensures `branch` exists on the repo. Idempotent on
|
||||
// 409/422 (already-exists). main is a no-op (assumed created by
|
||||
// EnsureRepo's auto_init).
|
||||
func (c *Client) EnsureBranch(ctx context.Context, org, repo, branch string) error {
|
||||
if branch == "" || branch == "main" {
|
||||
return nil
|
||||
}
|
||||
probe := fmt.Sprintf("/repos/%s/%s/branches/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), url.PathEscape(branch))
|
||||
status, _, err := c.do(ctx, http.MethodGet, probe, nil, nil)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if status != http.StatusNotFound {
|
||||
return err
|
||||
}
|
||||
create := fmt.Sprintf("/repos/%s/%s/branches",
|
||||
url.PathEscape(org), url.PathEscape(repo))
|
||||
body := map[string]interface{}{
|
||||
"new_branch_name": branch,
|
||||
"old_branch_name": "main",
|
||||
}
|
||||
status, _, err = c.do(ctx, http.MethodPost, create, body, nil)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if status == http.StatusConflict || status == http.StatusUnprocessableEntity {
|
||||
// Raced with a parallel create — success.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// File CRUD
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
// File is the surface returned by GetFile / PutFile.
|
||||
type File struct {
|
||||
Path string `json:"path"`
|
||||
SHA string `json:"sha"`
|
||||
ContentBase64 string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Decoded returns the file's raw bytes (decoding ContentBase64). Gitea
|
||||
// embeds newlines in long base64 payloads; we strip them before decode.
|
||||
func (f *File) Decoded() ([]byte, error) {
|
||||
if f == nil {
|
||||
return nil, nil
|
||||
}
|
||||
clean := strings.ReplaceAll(f.ContentBase64, "\n", "")
|
||||
if clean == "" {
|
||||
return []byte{}, nil
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(clean)
|
||||
}
|
||||
|
||||
// commitFilePayload — body for create / update / delete file ops.
|
||||
type commitFilePayload struct {
|
||||
Message string `json:"message"`
|
||||
Content string `json:"content,omitempty"`
|
||||
SHA string `json:"sha,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Author *signature `json:"author,omitempty"`
|
||||
Committer *signature `json:"committer,omitempty"`
|
||||
}
|
||||
|
||||
type signature struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// PutFileOpts threads optional fields through PutFile without
|
||||
// polluting the common signature. Author/Email are forwarded as both
|
||||
// `author` and `committer` per Gitea convention.
|
||||
type PutFileOpts struct {
|
||||
AuthorName string
|
||||
AuthorEmail string
|
||||
}
|
||||
|
||||
// GetFile fetches the file at path on the given branch.
|
||||
//
|
||||
// Returns ErrFileNotFound on a plain 404, or ErrRepoNotFound when the
|
||||
// 404 body indicates the repository itself is missing (Gitea includes
|
||||
// "repository" in that response).
|
||||
func (c *Client) GetFile(ctx context.Context, org, repo, branch, path string) (File, error) {
|
||||
if org == "" || repo == "" || path == "" {
|
||||
return File{}, errors.New("gitea: GetFile requires non-empty org, repo, path")
|
||||
}
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if branch != "" {
|
||||
endpoint += "?ref=" + url.QueryEscape(branch)
|
||||
}
|
||||
var out File
|
||||
status, body, err := c.do(ctx, http.MethodGet, endpoint, nil, &out)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
if strings.Contains(strings.ToLower(string(body)), "repository") {
|
||||
return File{}, ErrRepoNotFound
|
||||
}
|
||||
return File{}, ErrFileNotFound
|
||||
}
|
||||
return File{}, err
|
||||
}
|
||||
if out.Type != "" && out.Type != "file" {
|
||||
return out, fmt.Errorf("gitea: GetFile: path is %q, not file", out.Type)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PutFile creates the file if absent, updates if present, or
|
||||
// short-circuits with no API write when the existing content is byte-
|
||||
// equal to `data`. The byte-equal short-circuit is the canonical
|
||||
// idempotency anchor across all four pre-CC2 clients.
|
||||
//
|
||||
// Returns:
|
||||
// - file: the post-write File (or pre-existing File on no-op)
|
||||
// - committed: true ONLY when the controller actually wrote bytes
|
||||
// - err: non-nil on transport / API errors. ErrRepoNotFound is
|
||||
// surfaced as-is so callers can re-queue with a typed
|
||||
// pending condition.
|
||||
//
|
||||
// Optional opts pass committer attribution (used by C2 environment-
|
||||
// controller for `author`/`committer` stamping).
|
||||
func (c *Client) PutFile(ctx context.Context, org, repo, branch, path string, data []byte, message string, opts ...PutFileOpts) (file File, committed bool, err error) {
|
||||
var auth *signature
|
||||
if len(opts) > 0 && (opts[0].AuthorName != "" || opts[0].AuthorEmail != "") {
|
||||
auth = &signature{Name: opts[0].AuthorName, Email: opts[0].AuthorEmail}
|
||||
}
|
||||
|
||||
// Probe existing.
|
||||
existing, gerr := c.GetFile(ctx, org, repo, branch, path)
|
||||
switch {
|
||||
case gerr == nil:
|
||||
// File exists. Byte-equal short-circuit.
|
||||
decoded, decErr := existing.Decoded()
|
||||
if decErr == nil && bytes.Equal(decoded, data) {
|
||||
return existing, false, nil
|
||||
}
|
||||
// Update via PUT with the existing SHA.
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
Content: base64.StdEncoding.EncodeToString(data),
|
||||
SHA: existing.SHA,
|
||||
Branch: branch,
|
||||
Author: auth,
|
||||
Committer: auth,
|
||||
}
|
||||
out, werr := c.contentsWrite(ctx, http.MethodPut, org, repo, path, body)
|
||||
if werr != nil {
|
||||
return File{}, false, werr
|
||||
}
|
||||
return out, true, nil
|
||||
case errors.Is(gerr, ErrFileNotFound):
|
||||
// Create via POST.
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
Content: base64.StdEncoding.EncodeToString(data),
|
||||
Branch: branch,
|
||||
Author: auth,
|
||||
Committer: auth,
|
||||
}
|
||||
out, werr := c.contentsWrite(ctx, http.MethodPost, org, repo, path, body)
|
||||
if werr != nil {
|
||||
return File{}, false, werr
|
||||
}
|
||||
return out, true, nil
|
||||
case errors.Is(gerr, ErrRepoNotFound):
|
||||
return File{}, false, ErrRepoNotFound
|
||||
default:
|
||||
return File{}, false, gerr
|
||||
}
|
||||
}
|
||||
|
||||
// contentsWrite issues a POST or PUT to the contents endpoint and
|
||||
// decodes the wrapper-or-direct response shape Gitea returns.
|
||||
func (c *Client) contentsWrite(ctx context.Context, method, org, repo, path string, body commitFilePayload) (File, error) {
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
status, respBody, err := c.do(ctx, method, endpoint, body, nil)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
return File{}, ErrRepoNotFound
|
||||
}
|
||||
return File{}, err
|
||||
}
|
||||
// Gitea wraps the file inside `content` on write responses; some
|
||||
// versions return the File directly.
|
||||
var wrapped struct {
|
||||
Content File `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &wrapped); err == nil && wrapped.Content.Path != "" {
|
||||
return wrapped.Content, nil
|
||||
}
|
||||
var direct File
|
||||
if err := json.Unmarshal(respBody, &direct); err == nil && direct.Path != "" {
|
||||
return direct, nil
|
||||
}
|
||||
// Empty body or unrecognized shape — return a minimal file.
|
||||
return File{Path: path}, nil
|
||||
}
|
||||
|
||||
// DeleteFile removes path from the repo at branch. Idempotent: a 404
|
||||
// from the probe returns (true, nil) — already absent.
|
||||
func (c *Client) DeleteFile(ctx context.Context, org, repo, branch, path, message string) (bool, error) {
|
||||
existing, err := c.GetFile(ctx, org, repo, branch, path)
|
||||
switch {
|
||||
case errors.Is(err, ErrFileNotFound), errors.Is(err, ErrRepoNotFound):
|
||||
return true, nil
|
||||
case err != nil:
|
||||
return false, err
|
||||
}
|
||||
body := commitFilePayload{
|
||||
Message: message,
|
||||
SHA: existing.SHA,
|
||||
Branch: branch,
|
||||
}
|
||||
endpoint := fmt.Sprintf("/repos/%s/%s/contents/%s",
|
||||
url.PathEscape(org), url.PathEscape(repo), pathEscapeSegments(path))
|
||||
if _, _, err := c.do(ctx, http.MethodDelete, endpoint, body, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// pathEscapeSegments escapes each path segment but preserves slashes.
|
||||
// `url.PathEscape` would encode "/" as "%2F", breaking Gitea's path
|
||||
// resolution.
|
||||
func pathEscapeSegments(p string) string {
|
||||
parts := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
for i, s := range parts {
|
||||
parts[i] = url.PathEscape(s)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
850
core/controllers/internal/gitea/client_test.go
Normal file
850
core/controllers/internal/gitea/client_test.go
Normal file
@ -0,0 +1,850 @@
|
||||
// gitea/client_test.go — exercises the SUPERSET surface with httptest
|
||||
// server fakes. The patterns mirror C1's gs.handle / C3's
|
||||
// fakeGitea.handler / C4's fakeGiteaServer.handler so consumers
|
||||
// migrating from per-controller clients see the same coverage.
|
||||
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// In-memory Gitea-API stub
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type fakeGitea struct {
|
||||
mu sync.Mutex
|
||||
orgs map[string]Org
|
||||
repos map[string]Repo
|
||||
branches map[string]map[string]bool
|
||||
files map[string]fakeFile
|
||||
calls map[string]int
|
||||
}
|
||||
|
||||
type fakeFile struct {
|
||||
content []byte
|
||||
sha string
|
||||
}
|
||||
|
||||
func newFake() *fakeGitea {
|
||||
return &fakeGitea{
|
||||
orgs: map[string]Org{},
|
||||
repos: map[string]Repo{},
|
||||
branches: map[string]map[string]bool{},
|
||||
files: map[string]fakeFile{},
|
||||
calls: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGitea) callCount(method, path string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls[method+" "+path]
|
||||
}
|
||||
|
||||
func (f *fakeGitea) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
f.calls[r.Method+" "+r.URL.Path]++
|
||||
f.mu.Unlock()
|
||||
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
http.Error(w, "no auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
p := r.URL.Path
|
||||
|
||||
// GET /api/v1/orgs/{slug}
|
||||
if r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/orgs/") && !strings.Contains(p[len("/api/v1/orgs/"):], "/") {
|
||||
slug := p[len("/api/v1/orgs/"):]
|
||||
f.mu.Lock()
|
||||
o, ok := f.orgs[slug]
|
||||
f.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "no such org", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, o)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/orgs
|
||||
if r.Method == http.MethodPost && p == "/api/v1/admin/orgs" {
|
||||
var body adminOrgCreate
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, dup := f.orgs[body.Username]; dup {
|
||||
http.Error(w, "exists", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
o := Org{
|
||||
ID: int64(len(f.orgs) + 1),
|
||||
Username: body.Username,
|
||||
FullName: body.FullName,
|
||||
Description: body.Description,
|
||||
Visibility: body.Visibility,
|
||||
}
|
||||
f.orgs[body.Username] = o
|
||||
writeJSON(w, http.StatusCreated, o)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/v1/orgs/{org}/repos
|
||||
if r.Method == http.MethodPost && strings.HasPrefix(p, "/api/v1/orgs/") && strings.HasSuffix(p, "/repos") {
|
||||
owner := strings.TrimSuffix(strings.TrimPrefix(p, "/api/v1/orgs/"), "/repos")
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.orgs[owner]; !ok {
|
||||
http.Error(w, "no such org", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body repoCreate
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
key := owner + "/" + body.Name
|
||||
if _, dup := f.repos[key]; dup {
|
||||
http.Error(w, "exists", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
rp := Repo{
|
||||
ID: int64(len(f.repos) + 1),
|
||||
Name: body.Name,
|
||||
FullName: key,
|
||||
Description: body.Description,
|
||||
Private: body.Private,
|
||||
DefaultBranch: body.DefaultBranch,
|
||||
}
|
||||
f.repos[key] = rp
|
||||
f.branches[key] = map[string]bool{"main": true}
|
||||
writeJSON(w, http.StatusCreated, rp)
|
||||
return
|
||||
}
|
||||
|
||||
// GET /api/v1/repos/{owner}/{repo}
|
||||
if r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/") && !strings.Contains(p, "/contents/") && !strings.Contains(p, "/branches") {
|
||||
rest := strings.TrimRight(strings.TrimPrefix(p, "/api/v1/repos/"), "/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
rp, ok := f.repos[parts[0]+"/"+parts[1]]
|
||||
f.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "no such repo", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rp)
|
||||
return
|
||||
}
|
||||
|
||||
// GET /api/v1/repos/{owner}/{repo}/branches/{branch}
|
||||
if r.Method == http.MethodGet && strings.Contains(p, "/branches/") {
|
||||
rest := strings.TrimPrefix(p, "/api/v1/repos/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 4 {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repoKey := parts[0] + "/" + parts[1]
|
||||
branch := parts[3]
|
||||
f.mu.Lock()
|
||||
ok := f.branches[repoKey] != nil && f.branches[repoKey][branch]
|
||||
f.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "no branch", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": branch})
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/v1/repos/{owner}/{repo}/branches
|
||||
if r.Method == http.MethodPost && strings.HasSuffix(p, "/branches") {
|
||||
rest := strings.TrimPrefix(p, "/api/v1/repos/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 3 {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repoKey := parts[0] + "/" + parts[1]
|
||||
var body struct {
|
||||
NewBranchName string `json:"new_branch_name"`
|
||||
OldBranchName string `json:"old_branch_name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
if f.branches[repoKey] == nil {
|
||||
f.branches[repoKey] = map[string]bool{}
|
||||
}
|
||||
if f.branches[repoKey][body.NewBranchName] {
|
||||
f.mu.Unlock()
|
||||
http.Error(w, "exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
f.branches[repoKey][body.NewBranchName] = true
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
|
||||
// /api/v1/repos/{owner}/{repo}/contents/{path}
|
||||
if strings.HasPrefix(p, "/api/v1/repos/") && strings.Contains(p, "/contents/") {
|
||||
rest := strings.TrimPrefix(p, "/api/v1/repos/")
|
||||
idx := strings.Index(rest, "/contents/")
|
||||
if idx < 0 {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ownerRepo := rest[:idx]
|
||||
filePath := rest[idx+len("/contents/"):]
|
||||
ownerRepoParts := strings.SplitN(ownerRepo, "/", 2)
|
||||
if len(ownerRepoParts) != 2 {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repoKey := ownerRepoParts[0] + "/" + ownerRepoParts[1]
|
||||
|
||||
// Branch resolution: query for GET, body for POST/PUT/DELETE.
|
||||
branch := r.URL.Query().Get("ref")
|
||||
var bodyBytes []byte
|
||||
if r.Method != http.MethodGet {
|
||||
bodyBytes, _ = io.ReadAll(r.Body)
|
||||
if branch == "" {
|
||||
var probe commitFilePayload
|
||||
_ = json.Unmarshal(bodyBytes, &probe)
|
||||
branch = probe.Branch
|
||||
}
|
||||
}
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
fileKey := repoKey + "/" + branch + "/" + filePath
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, repoOK := f.repos[repoKey]
|
||||
if !repoOK {
|
||||
http.Error(w, `{"message":"The target couldn't be found, repository missing."}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
ff, ok := f.files[fileKey]
|
||||
if !ok {
|
||||
http.Error(w, "no such file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, File{
|
||||
Path: filePath,
|
||||
SHA: ff.sha,
|
||||
Type: "file",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(ff.content),
|
||||
})
|
||||
case http.MethodPost, http.MethodPut:
|
||||
var body commitFilePayload
|
||||
_ = json.Unmarshal(bodyBytes, &body)
|
||||
decoded, _ := base64.StdEncoding.DecodeString(body.Content)
|
||||
if r.Method == http.MethodPost {
|
||||
if _, dup := f.files[fileKey]; dup {
|
||||
http.Error(w, "exists", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
}
|
||||
prevSHA := body.SHA
|
||||
if prevSHA == "" {
|
||||
prevSHA = "sha"
|
||||
}
|
||||
newSHA := prevSHA + "+1"
|
||||
f.files[fileKey] = fakeFile{content: decoded, sha: newSHA}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"content": File{
|
||||
Path: filePath,
|
||||
SHA: newSHA,
|
||||
Type: "file",
|
||||
},
|
||||
})
|
||||
case http.MethodDelete:
|
||||
if _, ok := f.files[fileKey]; !ok {
|
||||
http.Error(w, "no", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(f.files, fileKey)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "unhandled "+r.Method+" "+p, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func newClientWithFake(t *testing.T, fake *fakeGitea) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(fake.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL, "test-token")
|
||||
c.HTTP = srv.Client()
|
||||
return c
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Org tests
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
func TestEnsureOrg_FindHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{ID: 1, Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
|
||||
o, err := c.EnsureOrg(context.Background(), "acme", "ACME", "desc", "private")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureOrg: %v", err)
|
||||
}
|
||||
if o.ID != 1 || o.Username != "acme" {
|
||||
t.Fatalf("got %+v", o)
|
||||
}
|
||||
if got := fake.callCount(http.MethodGet, "/api/v1/orgs/acme"); got != 1 {
|
||||
t.Errorf("expected 1 GET, got %d", got)
|
||||
}
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/admin/orgs"); got != 0 {
|
||||
t.Errorf("expected 0 POST when org pre-exists, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOrg_CreatesWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
c := newClientWithFake(t, fake)
|
||||
|
||||
o, err := c.EnsureOrg(context.Background(), "newone", "NewOne", "", "private")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureOrg: %v", err)
|
||||
}
|
||||
if o.Username != "newone" || o.ID == 0 {
|
||||
t.Errorf("expected created org, got %+v", o)
|
||||
}
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/admin/orgs"); got != 1 {
|
||||
t.Errorf("expected 1 POST, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOrg_422Race(t *testing.T) {
|
||||
t.Parallel()
|
||||
step := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/orgs/raced":
|
||||
step++
|
||||
if step == 1 {
|
||||
http.Error(w, "miss", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(Org{ID: 99, Username: "raced"})
|
||||
case "POST /api/v1/admin/orgs":
|
||||
http.Error(w, "duplicate", http.StatusUnprocessableEntity)
|
||||
default:
|
||||
http.Error(w, "unhandled", http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
c.HTTP = srv.Client()
|
||||
|
||||
o, err := c.EnsureOrg(context.Background(), "raced", "Raced", "", "private")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureOrg: %v", err)
|
||||
}
|
||||
if o.ID != 99 {
|
||||
t.Errorf("expected re-find ID 99, got %d", o.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrg_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
c := newClientWithFake(t, fake)
|
||||
_, err := c.GetOrg(context.Background(), "ghost")
|
||||
if !errors.Is(err, ErrOrgNotFound) {
|
||||
t.Errorf("expected ErrOrgNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrg_ServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"message":"boom"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
c.HTTP = srv.Client()
|
||||
|
||||
_, err := c.GetOrg(context.Background(), "acme")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if errors.Is(err, ErrOrgNotFound) {
|
||||
t.Errorf("500 should not map to ErrOrgNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrg_EmptySlug(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New("http://nope", "tok")
|
||||
_, err := c.GetOrg(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Error("expected error on empty slug")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Repo tests
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
func TestEnsureRepo_FindHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{ID: 1, Username: "acme"}
|
||||
fake.repos["acme/site"] = Repo{ID: 7, Name: "site", FullName: "acme/site"}
|
||||
c := newClientWithFake(t, fake)
|
||||
|
||||
r, err := c.EnsureRepo(context.Background(), "acme", "site", "desc", false)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
if r.ID != 7 {
|
||||
t.Errorf("expected ID 7, got %d", r.ID)
|
||||
}
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/orgs/acme/repos"); got != 0 {
|
||||
t.Errorf("expected 0 POST when repo pre-exists, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRepo_CreatesWithPrivate(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
|
||||
r, err := c.EnsureRepo(context.Background(), "acme", "site", "desc", true)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
if r.Name != "site" || !r.Private {
|
||||
t.Errorf("expected created private repo, got %+v", r)
|
||||
}
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/orgs/acme/repos"); got != 1 {
|
||||
t.Errorf("expected 1 POST, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRepo_OrgMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
c := newClientWithFake(t, fake)
|
||||
_, err := c.EnsureRepo(context.Background(), "missing", "site", "", false)
|
||||
if !errors.Is(err, ErrOrgNotFound) {
|
||||
t.Errorf("expected ErrOrgNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRepo_422Race(t *testing.T) {
|
||||
t.Parallel()
|
||||
step := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/acme/site":
|
||||
step++
|
||||
if step == 1 {
|
||||
http.Error(w, "miss", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(Repo{ID: 42, Name: "site"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/orgs/acme/repos":
|
||||
http.Error(w, "duplicate", http.StatusUnprocessableEntity)
|
||||
default:
|
||||
http.Error(w, "unhandled", http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
c.HTTP = srv.Client()
|
||||
|
||||
r, err := c.EnsureRepo(context.Background(), "acme", "site", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
if r.ID != 42 {
|
||||
t.Errorf("expected re-find ID 42, got %d", r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Branch tests
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
func TestEnsureBranch_MainNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
c := newClientWithFake(t, fake)
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", "main"); err != nil {
|
||||
t.Errorf("main: %v", err)
|
||||
}
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", ""); err != nil {
|
||||
t.Errorf("empty: %v", err)
|
||||
}
|
||||
for k, v := range fake.calls {
|
||||
if v != 0 {
|
||||
t.Errorf("EnsureBranch main/empty issued %s: %d calls", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBranch_CreatesDevelop(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", "develop"); err != nil {
|
||||
t.Fatalf("EnsureBranch develop: %v", err)
|
||||
}
|
||||
if !fake.branches["acme/site"]["develop"] {
|
||||
t.Error("develop should have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBranch_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", "develop"); err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if err := c.EnsureBranch(context.Background(), "acme", "site", "develop"); err != nil {
|
||||
t.Fatalf("second (idempotent): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// File tests
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
func TestPutFile_CreateNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
|
||||
_, committed, err := c.PutFile(context.Background(), "acme", "site", "main",
|
||||
"clusters/x/y.yaml", []byte("hello\n"), "init")
|
||||
if err != nil {
|
||||
t.Fatalf("PutFile: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("expected committed=true on create")
|
||||
}
|
||||
if got := fake.callCount(http.MethodPost, "/api/v1/repos/acme/site/contents/clusters/x/y.yaml"); got != 1 {
|
||||
t.Errorf("expected 1 POST, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFile_UpdateExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := c.PutFile(context.Background(), "acme", "site", "main", "f.yaml", []byte("v1\n"), "init"); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
_, committed, err := c.PutFile(context.Background(), "acme", "site", "main",
|
||||
"f.yaml", []byte("v2\n"), "bump")
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("expected committed=true on update")
|
||||
}
|
||||
if got := fake.callCount(http.MethodPut, "/api/v1/repos/acme/site/contents/f.yaml"); got != 1 {
|
||||
t.Errorf("expected 1 PUT, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFile_ByteEqualNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := c.PutFile(context.Background(), "acme", "site", "main", "f.yaml", []byte("same\n"), "init"); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
postsBefore := fake.callCount(http.MethodPost, "/api/v1/repos/acme/site/contents/f.yaml")
|
||||
putsBefore := fake.callCount(http.MethodPut, "/api/v1/repos/acme/site/contents/f.yaml")
|
||||
|
||||
_, committed, err := c.PutFile(context.Background(), "acme", "site", "main",
|
||||
"f.yaml", []byte("same\n"), "noop")
|
||||
if err != nil {
|
||||
t.Fatalf("noop: %v", err)
|
||||
}
|
||||
if committed {
|
||||
t.Error("expected committed=false on byte-equal write")
|
||||
}
|
||||
|
||||
postsAfter := fake.callCount(http.MethodPost, "/api/v1/repos/acme/site/contents/f.yaml")
|
||||
putsAfter := fake.callCount(http.MethodPut, "/api/v1/repos/acme/site/contents/f.yaml")
|
||||
if postsAfter != postsBefore || putsAfter != putsBefore {
|
||||
t.Errorf("byte-equal PutFile issued writes: POST %d→%d, PUT %d→%d",
|
||||
postsBefore, postsAfter, putsBefore, putsAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFile_WithAuthor(t *testing.T) {
|
||||
t.Parallel()
|
||||
captured := struct {
|
||||
body []byte
|
||||
sync.Mutex
|
||||
}{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
http.Error(w, "miss", http.StatusNotFound)
|
||||
case http.MethodPost:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
captured.Lock()
|
||||
captured.body = b
|
||||
captured.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"content":{"path":"f.yaml","sha":"sha1"}}`)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
c.HTTP = srv.Client()
|
||||
|
||||
_, _, err := c.PutFile(context.Background(), "acme", "site", "main",
|
||||
"f.yaml", []byte("hello"), "init",
|
||||
PutFileOpts{AuthorName: "bot", AuthorEmail: "bot@x"})
|
||||
if err != nil {
|
||||
t.Fatalf("PutFile: %v", err)
|
||||
}
|
||||
captured.Lock()
|
||||
defer captured.Unlock()
|
||||
var got commitFilePayload
|
||||
if err := json.Unmarshal(captured.body, &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Author == nil || got.Author.Name != "bot" || got.Author.Email != "bot@x" {
|
||||
t.Errorf("expected author=bot/bot@x, got %+v", got.Author)
|
||||
}
|
||||
if got.Committer == nil || got.Committer.Name != "bot" {
|
||||
t.Errorf("expected committer mirrored, got %+v", got.Committer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFile_RepoNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"The target couldn't be found, repository missing."}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "tok")
|
||||
c.HTTP = srv.Client()
|
||||
|
||||
_, _, err := c.PutFile(context.Background(), "acme", "missing", "main", "f.yaml", []byte("x"), "msg")
|
||||
if !errors.Is(err, ErrRepoNotFound) {
|
||||
t.Errorf("expected ErrRepoNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFile_OK(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
if _, _, err := c.PutFile(context.Background(), "acme", "site", "main", "f.yaml", []byte("hello\n"), "init"); err != nil {
|
||||
t.Fatalf("seed PutFile: %v", err)
|
||||
}
|
||||
|
||||
f, err := c.GetFile(context.Background(), "acme", "site", "main", "f.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFile: %v", err)
|
||||
}
|
||||
if f.Path != "f.yaml" {
|
||||
t.Errorf("expected path f.yaml, got %q", f.Path)
|
||||
}
|
||||
dec, err := f.Decoded()
|
||||
if err != nil {
|
||||
t.Fatalf("Decoded: %v", err)
|
||||
}
|
||||
if string(dec) != "hello\n" {
|
||||
t.Errorf("expected decoded=hello, got %q", dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFile_FileNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
|
||||
_, err := c.GetFile(context.Background(), "acme", "site", "main", "absent.yaml")
|
||||
if !errors.Is(err, ErrFileNotFound) {
|
||||
t.Errorf("expected ErrFileNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFile_RepoNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
|
||||
_, err := c.GetFile(context.Background(), "acme", "missing", "main", "f.yaml")
|
||||
if !errors.Is(err, ErrRepoNotFound) {
|
||||
t.Errorf("expected ErrRepoNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFile_Present(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
if _, _, err := c.PutFile(context.Background(), "acme", "site", "main", "f.yaml", []byte("x"), "init"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := c.DeleteFile(context.Background(), "acme", "site", "main", "f.yaml", "withdraw")
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("DeleteFile: %v deleted=%v", err, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFile_AbsentIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := newFake()
|
||||
fake.orgs["acme"] = Org{Username: "acme"}
|
||||
c := newClientWithFake(t, fake)
|
||||
if _, err := c.EnsureRepo(context.Background(), "acme", "site", "", false); err != nil {
|
||||
t.Fatalf("EnsureRepo: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := c.DeleteFile(context.Background(), "acme", "site", "main", "absent.yaml", "no-op")
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("idempotent DeleteFile: %v deleted=%v", err, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Error helpers
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
func TestIsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsNotFound(nil) {
|
||||
t.Error("nil should not be NotFound")
|
||||
}
|
||||
if !IsNotFound(ErrOrgNotFound) || !IsNotFound(ErrRepoNotFound) || !IsNotFound(ErrFileNotFound) {
|
||||
t.Error("typed sentinels should be NotFound")
|
||||
}
|
||||
if !IsNotFound(&HTTPError{Status: 404}) {
|
||||
t.Error("HTTPError 404 should be NotFound")
|
||||
}
|
||||
if IsNotFound(&HTTPError{Status: 500}) {
|
||||
t.Error("HTTPError 500 should not be NotFound")
|
||||
}
|
||||
if IsNotFound(errors.New("plain")) {
|
||||
t.Error("plain error should not be NotFound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsConflict(nil) {
|
||||
t.Error("nil should not be Conflict")
|
||||
}
|
||||
if !IsConflict(&HTTPError{Status: 409}) {
|
||||
t.Error("HTTPError 409 should be Conflict")
|
||||
}
|
||||
if IsConflict(&HTTPError{Status: 404}) {
|
||||
t.Error("HTTPError 404 should not be Conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFile_Decoded(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := File{ContentBase64: base64.StdEncoding.EncodeToString([]byte("abc"))}
|
||||
dec, err := f.Decoded()
|
||||
if err != nil {
|
||||
t.Fatalf("Decoded: %v", err)
|
||||
}
|
||||
if string(dec) != "abc" {
|
||||
t.Errorf("expected abc, got %q", dec)
|
||||
}
|
||||
|
||||
f2 := File{ContentBase64: "YWJj\nZGVm\n"}
|
||||
dec2, err := f2.Decoded()
|
||||
if err != nil {
|
||||
t.Fatalf("Decoded with newlines: %v", err)
|
||||
}
|
||||
if string(dec2) != "abcdef" {
|
||||
t.Errorf("expected abcdef, got %q", dec2)
|
||||
}
|
||||
|
||||
var nilF *File
|
||||
if dec, err := nilF.Decoded(); err != nil || dec != nil {
|
||||
t.Errorf("nil File should return nil bytes, got %v %v", dec, err)
|
||||
}
|
||||
}
|
||||
@ -24,8 +24,8 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/organization/internal/controller"
|
||||
"github.com/openova-io/openova/core/controllers/organization/internal/gitea"
|
||||
orgapi "github.com/openova-io/openova/core/controllers/organization/internal/orgapi"
|
||||
)
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ import (
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/organization/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/organization/internal/gitops"
|
||||
orgapi "github.com/openova-io/openova/core/controllers/organization/internal/orgapi"
|
||||
)
|
||||
@ -186,8 +186,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
|
||||
branch = "main"
|
||||
}
|
||||
for path, data := range manifests {
|
||||
if _, err := r.GiteaClient.PutFile(ctx,
|
||||
gOrg.Username, repoName, path, branch, data,
|
||||
if _, _, err := r.GiteaClient.PutFile(ctx,
|
||||
gOrg.Username, repoName, branch, path, data,
|
||||
fmt.Sprintf("organization-controller: reconcile %s for %s", path, org.Spec.Slug)); err != nil {
|
||||
return r.fail(ctx, &org, "GitopsWriteFailed",
|
||||
fmt.Sprintf("write %s: %s", path, err))
|
||||
|
||||
@ -36,7 +36,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
"github.com/openova-io/openova/core/controllers/organization/internal/gitea"
|
||||
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
||||
orgapi "github.com/openova-io/openova/core/controllers/organization/internal/orgapi"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@ -228,12 +228,11 @@ func (g *giteaServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, gitea.FileContent{
|
||||
Name: pathLeaf(filePath),
|
||||
Path: filePath,
|
||||
SHA: f.sha,
|
||||
Type: "file",
|
||||
Content: base64.StdEncoding.EncodeToString(f.content),
|
||||
writeJSON(w, http.StatusOK, gitea.File{
|
||||
Path: filePath,
|
||||
SHA: f.sha,
|
||||
Type: "file",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(f.content),
|
||||
})
|
||||
return
|
||||
case http.MethodPost, http.MethodPut:
|
||||
@ -266,8 +265,7 @@ func (g *giteaServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
content: data,
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"content": gitea.FileContent{
|
||||
Name: pathLeaf(filePath),
|
||||
"content": gitea.File{
|
||||
Path: filePath,
|
||||
SHA: g.files[key].sha,
|
||||
Type: "file",
|
||||
@ -281,13 +279,6 @@ func (g *giteaServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func pathLeaf(p string) string {
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 {
|
||||
return p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
|
||||
@ -1,329 +0,0 @@
|
||||
// Package gitea is a minimal Gitea Admin REST API client used by
|
||||
// organization-controller (slice C1) to ensure a per-Organization Gitea
|
||||
// Org exists. The client is intentionally narrow — only the surface
|
||||
// the reconciler needs — and follows the exact idiom of
|
||||
// products/catalyst/bootstrap/api/internal/keycloak/client.go:
|
||||
//
|
||||
// - HTTP client supplied at New (tests inject a fake roundtripper)
|
||||
// - 200/201/204 success cases parsed explicitly
|
||||
// - 404 surfaces as a typed error sentinel
|
||||
// - 409 from "create" surfaces as a typed sentinel so callers can
|
||||
// re-find for idempotency
|
||||
//
|
||||
// Slice C2 (environment-controller) will likely need to write Gitea
|
||||
// repos as well; if so the contract is captured here so the client can
|
||||
// be extracted to core/pkg/gitea-client/ without an API change.
|
||||
//
|
||||
// Endpoints used (Gitea Admin API, version 1.22):
|
||||
//
|
||||
// GET /api/v1/orgs/{org}
|
||||
// POST /api/v1/admin/orgs (admin-only — full Org create)
|
||||
// GET /api/v1/repos/{owner}/{repo}
|
||||
// POST /api/v1/orgs/{org}/repos
|
||||
//
|
||||
// Authentication: a static admin access-token (the catalyst-api
|
||||
// service-account token managed by Sovereign-admin) — passed via
|
||||
// `Authorization: token <hex>` header per Gitea convention.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrOrgNotFound is returned by GetOrg when the slug does not resolve.
|
||||
var ErrOrgNotFound = errors.New("gitea: organization not found")
|
||||
|
||||
// ErrRepoNotFound is returned by GetRepo when the slug does not resolve.
|
||||
var ErrRepoNotFound = errors.New("gitea: repository not found")
|
||||
|
||||
// errOrgAlreadyExists is the internal sentinel for the EnsureOrg 422/409
|
||||
// race path. Gitea returns 422 (not 409) on duplicate-name; we accept
|
||||
// either for forward-compatibility.
|
||||
var errOrgAlreadyExists = errors.New("gitea: organization already exists")
|
||||
|
||||
// errRepoAlreadyExists mirrors errOrgAlreadyExists for repos.
|
||||
var errRepoAlreadyExists = errors.New("gitea: repository already exists")
|
||||
|
||||
// Client wraps the Gitea Admin REST API.
|
||||
type Client struct {
|
||||
addr string // e.g. "https://gitea.hfmp.openova.io"
|
||||
token string // admin personal-access token
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client with a 30s default timeout.
|
||||
func New(addr, token string) *Client {
|
||||
return NewWithHTTP(addr, token, &http.Client{Timeout: 30 * time.Second})
|
||||
}
|
||||
|
||||
// NewWithHTTP returns a Client using the supplied http.Client (tests
|
||||
// inject a fake roundtripper).
|
||||
func NewWithHTTP(addr, token string, hc *http.Client) *Client {
|
||||
return &Client{
|
||||
addr: strings.TrimRight(addr, "/"),
|
||||
token: token,
|
||||
http: hc,
|
||||
}
|
||||
}
|
||||
|
||||
// Org is the slice of Gitea Organization fields organization-controller
|
||||
// reads/writes. Gitea's full OrganizationRepresentation has dozens of
|
||||
// fields; we surface only the few that drive create + status.
|
||||
type Org struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Username string `json:"username,omitempty"` // the slug (Gitea calls it "username" for orgs)
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"` // public|limited|private
|
||||
}
|
||||
|
||||
// adminOrgCreate is the payload for POST /admin/orgs which requires a
|
||||
// `username` (the org slug) and an admin-supplied user owner. The
|
||||
// catalyst-api service-account is the owner — it owns every Org until
|
||||
// the reconciler explicitly transfers; future slices add owner
|
||||
// re-bind once Keycloak federation lands.
|
||||
type adminOrgCreate struct {
|
||||
Username string `json:"username"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// Repo is the slice of Gitea Repository fields the controller reads/writes.
|
||||
type Repo struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
}
|
||||
|
||||
// repoCreate is the request shape for POST /orgs/{org}/repos.
|
||||
type repoCreate struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private"`
|
||||
AutoInit bool `json:"auto_init"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
}
|
||||
|
||||
// GetOrg fetches the Gitea Org by slug. Returns ErrOrgNotFound on 404.
|
||||
func (c *Client) GetOrg(ctx context.Context, slug string) (Org, error) {
|
||||
u := fmt.Sprintf("%s/api/v1/orgs/%s", c.addr, slug)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return Org{}, err
|
||||
}
|
||||
c.auth(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Org{}, fmt.Errorf("gitea: GET org: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
var o Org
|
||||
if err := json.Unmarshal(body, &o); err != nil {
|
||||
return Org{}, fmt.Errorf("gitea: decode org: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
case http.StatusNotFound:
|
||||
return Org{}, ErrOrgNotFound
|
||||
default:
|
||||
return Org{}, fmt.Errorf("gitea: GET org %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOrg creates a Gitea Org via the admin endpoint. On 422/409 the
|
||||
// internal errOrgAlreadyExists sentinel is returned so EnsureOrg can
|
||||
// re-find idempotently.
|
||||
func (c *Client) CreateOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) {
|
||||
if visibility == "" {
|
||||
visibility = "private" // default — per-Org content stays private until explicit promotion
|
||||
}
|
||||
body, err := json.Marshal(adminOrgCreate{
|
||||
Username: slug,
|
||||
FullName: fullName,
|
||||
Description: description,
|
||||
Visibility: visibility,
|
||||
})
|
||||
if err != nil {
|
||||
return Org{}, err
|
||||
}
|
||||
u := fmt.Sprintf("%s/api/v1/admin/orgs", c.addr)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Org{}, err
|
||||
}
|
||||
c.auth(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Org{}, fmt.Errorf("gitea: POST org: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated:
|
||||
var o Org
|
||||
if err := json.Unmarshal(respBody, &o); err != nil {
|
||||
return Org{}, fmt.Errorf("gitea: decode created org: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
case http.StatusUnprocessableEntity, http.StatusConflict:
|
||||
// Gitea returns 422 on duplicate-username and 409 in some
|
||||
// historical versions; treat both as "already exists".
|
||||
return Org{}, errOrgAlreadyExists
|
||||
default:
|
||||
return Org{}, fmt.Errorf("gitea: POST org %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureOrg is the find-or-create shorthand. Returns the Org with its
|
||||
// numeric ID populated. Mirrors keycloak.EnsureGroup semantics — an
|
||||
// existing Org's metadata is NOT mutated to match the desired
|
||||
// fullName/description; the controller treats those as soft-attributes
|
||||
// (operators rename via the Gitea UI without conflict).
|
||||
func (c *Client) EnsureOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) {
|
||||
existing, err := c.GetOrg(ctx, slug)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, ErrOrgNotFound) {
|
||||
return Org{}, fmt.Errorf("gitea.EnsureOrg: get: %w", err)
|
||||
}
|
||||
|
||||
created, err := c.CreateOrg(ctx, slug, fullName, description, visibility)
|
||||
if errors.Is(err, errOrgAlreadyExists) {
|
||||
// 422/409 race — re-find.
|
||||
o, ferr := c.GetOrg(ctx, slug)
|
||||
if ferr != nil {
|
||||
return Org{}, fmt.Errorf("gitea.EnsureOrg: re-find after 422/409: %w", ferr)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Org{}, fmt.Errorf("gitea.EnsureOrg: create: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// GetRepo fetches the Gitea repo by owner/name. Returns ErrRepoNotFound on 404.
|
||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (Repo, error) {
|
||||
u := fmt.Sprintf("%s/api/v1/repos/%s/%s", c.addr, owner, name)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return Repo{}, err
|
||||
}
|
||||
c.auth(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Repo{}, fmt.Errorf("gitea: GET repo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
var r Repo
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return Repo{}, fmt.Errorf("gitea: decode repo: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
case http.StatusNotFound:
|
||||
return Repo{}, ErrRepoNotFound
|
||||
default:
|
||||
return Repo{}, fmt.Errorf("gitea: GET repo %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRepo creates a repo under the given Org. autoInit=true makes
|
||||
// Gitea seed an initial empty commit so the default branch exists.
|
||||
func (c *Client) CreateRepo(ctx context.Context, org, name, description string, private bool, autoInit bool, defaultBranch string) (Repo, error) {
|
||||
if defaultBranch == "" {
|
||||
defaultBranch = "main"
|
||||
}
|
||||
body, err := json.Marshal(repoCreate{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Private: private,
|
||||
AutoInit: autoInit,
|
||||
DefaultBranch: defaultBranch,
|
||||
})
|
||||
if err != nil {
|
||||
return Repo{}, err
|
||||
}
|
||||
u := fmt.Sprintf("%s/api/v1/orgs/%s/repos", c.addr, org)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Repo{}, err
|
||||
}
|
||||
c.auth(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Repo{}, fmt.Errorf("gitea: POST repo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated:
|
||||
var r Repo
|
||||
if err := json.Unmarshal(respBody, &r); err != nil {
|
||||
return Repo{}, fmt.Errorf("gitea: decode created repo: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
case http.StatusUnprocessableEntity, http.StatusConflict:
|
||||
return Repo{}, errRepoAlreadyExists
|
||||
default:
|
||||
return Repo{}, fmt.Errorf("gitea: POST repo %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureRepo is the find-or-create shorthand for repos.
|
||||
func (c *Client) EnsureRepo(ctx context.Context, org, name, description string, private bool) (Repo, error) {
|
||||
existing, err := c.GetRepo(ctx, org, name)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, ErrRepoNotFound) {
|
||||
return Repo{}, fmt.Errorf("gitea.EnsureRepo: get: %w", err)
|
||||
}
|
||||
created, err := c.CreateRepo(ctx, org, name, description, private, true, "main")
|
||||
if errors.Is(err, errRepoAlreadyExists) {
|
||||
r, ferr := c.GetRepo(ctx, org, name)
|
||||
if ferr != nil {
|
||||
return Repo{}, fmt.Errorf("gitea.EnsureRepo: re-find after 422/409: %w", ferr)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Repo{}, fmt.Errorf("gitea.EnsureRepo: create: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// auth sets the Authorization header. Gitea accepts both
|
||||
// `token <pat>` and `Bearer <pat>`; the canonical Gitea form is
|
||||
// `token <pat>`.
|
||||
func (c *Client) auth(r *http.Request) {
|
||||
r.Header.Set("Authorization", "token "+c.token)
|
||||
r.Header.Set("Accept", "application/json")
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
// gitea/client_test.go — exercise the find-or-create paths against an
|
||||
// in-process httptest stub. These are unit-level — the bigger
|
||||
// integration tests live in the reconciler package which exercises
|
||||
// this client end-to-end.
|
||||
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newStub(t *testing.T, h http.Handler) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return New(srv.URL, "tok")
|
||||
}
|
||||
|
||||
func TestEnsureOrg_FindHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := map[string]int{}
|
||||
c := newStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls[r.Method+" "+r.URL.Path]++
|
||||
if r.URL.Path == "/api/v1/orgs/acme" && r.Method == http.MethodGet {
|
||||
_ = json.NewEncoder(w).Encode(Org{ID: 1, Username: "acme"})
|
||||
return
|
||||
}
|
||||
http.Error(w, "unexpected", http.StatusInternalServerError)
|
||||
}))
|
||||
o, err := c.EnsureOrg(context.Background(), "acme", "ACME", "desc", "private")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureOrg: %v", err)
|
||||
}
|
||||
if o.ID != 1 || o.Username != "acme" {
|
||||
t.Fatalf("got %+v", o)
|
||||
}
|
||||
if calls["GET /api/v1/orgs/acme"] != 1 {
|
||||
t.Errorf("expected 1 GET, got %d", calls["GET /api/v1/orgs/acme"])
|
||||
}
|
||||
if calls["POST /api/v1/admin/orgs"] != 0 {
|
||||
t.Errorf("expected 0 POST when org pre-exists, got %d", calls["POST /api/v1/admin/orgs"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOrg_CreatesWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
state := map[string]Org{}
|
||||
c := newStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/orgs/"):
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/api/v1/orgs/")
|
||||
if o, ok := state[slug]; ok {
|
||||
_ = json.NewEncoder(w).Encode(o)
|
||||
return
|
||||
}
|
||||
http.Error(w, "nope", http.StatusNotFound)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/orgs":
|
||||
var b struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
o := Org{ID: 7, Username: b.Username}
|
||||
state[b.Username] = o
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(o)
|
||||
default:
|
||||
http.Error(w, "unhandled", http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
o, err := c.EnsureOrg(context.Background(), "newone", "NewOne", "", "private")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureOrg: %v", err)
|
||||
}
|
||||
if o.ID != 7 {
|
||||
t.Errorf("expected new ID 7, got %d", o.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOrg_409Race(t *testing.T) {
|
||||
t.Parallel()
|
||||
// First GET → 404; POST → 422 (raced); second GET → 200.
|
||||
step := 0
|
||||
c := newStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/orgs/raced":
|
||||
step++
|
||||
if step == 1 {
|
||||
http.Error(w, "miss", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(Org{ID: 99, Username: "raced"})
|
||||
case "POST /api/v1/admin/orgs":
|
||||
http.Error(w, "duplicate", http.StatusUnprocessableEntity)
|
||||
default:
|
||||
http.Error(w, "unhandled", http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
o, err := c.EnsureOrg(context.Background(), "raced", "Raced", "", "private")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureOrg: %v", err)
|
||||
}
|
||||
if o.ID != 99 {
|
||||
t.Errorf("expected re-find to return existing ID 99, got %d", o.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFile_ByteEqualNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
files := map[string]string{
|
||||
"acme/repo/foo.yaml": "abc",
|
||||
}
|
||||
postCalls := 0
|
||||
putCalls := 0
|
||||
c := newStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/")
|
||||
// Strip /contents/ to get owner/repo + path.
|
||||
idx := strings.Index(key, "/contents/")
|
||||
if idx < 0 {
|
||||
http.Error(w, "bad", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ownerRepo := key[:idx]
|
||||
filePath := key[idx+len("/contents/"):]
|
||||
joined := ownerRepo + "/" + filePath
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if v, ok := files[joined]; ok {
|
||||
_ = json.NewEncoder(w).Encode(FileContent{
|
||||
Path: filePath,
|
||||
SHA: "old-sha",
|
||||
Type: "file",
|
||||
Content: base64.StdEncoding.EncodeToString([]byte(v)),
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "no", http.StatusNotFound)
|
||||
case http.MethodPost:
|
||||
postCalls++
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": FileContent{Path: filePath, SHA: "new-sha"}})
|
||||
case http.MethodPut:
|
||||
putCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": FileContent{Path: filePath, SHA: "updated-sha"}})
|
||||
}
|
||||
}))
|
||||
|
||||
// Existing file, byte-equal write — no PUT.
|
||||
if _, err := c.PutFile(context.Background(), "acme", "repo", "foo.yaml", "main", []byte("abc"), "no-op"); err != nil {
|
||||
t.Fatalf("PutFile noop: %v", err)
|
||||
}
|
||||
if putCalls != 0 || postCalls != 0 {
|
||||
t.Errorf("byte-equal PutFile should not write: got POST=%d PUT=%d", postCalls, putCalls)
|
||||
}
|
||||
|
||||
// Missing file → POST.
|
||||
if _, err := c.PutFile(context.Background(), "acme", "repo", "bar.yaml", "main", []byte("xyz"), ""); err != nil {
|
||||
t.Fatalf("PutFile create: %v", err)
|
||||
}
|
||||
if postCalls != 1 {
|
||||
t.Errorf("expected 1 POST for new file, got %d", postCalls)
|
||||
}
|
||||
|
||||
// Mutated existing → PUT.
|
||||
if _, err := c.PutFile(context.Background(), "acme", "repo", "foo.yaml", "main", []byte("changed"), ""); err != nil {
|
||||
t.Fatalf("PutFile update: %v", err)
|
||||
}
|
||||
if putCalls != 1 {
|
||||
t.Errorf("expected 1 PUT for changed file, got %d", putCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrg_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newStub(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "no", http.StatusNotFound)
|
||||
}))
|
||||
_, err := c.GetOrg(context.Background(), "ghost")
|
||||
if !errors.Is(err, ErrOrgNotFound) {
|
||||
t.Errorf("expected ErrOrgNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@ -1,170 +0,0 @@
|
||||
// gitea/contents.go — minimal "contents API" surface for writing a file
|
||||
// at a known path via the Gitea HTTP API. Used by organization-controller
|
||||
// to materialize the vCluster HelmRelease into the per-Org Gitea repo.
|
||||
//
|
||||
// We deliberately avoid `git clone/push` here: organization-controller
|
||||
// is a single-purpose binary, the repo content is one tiny HelmRelease
|
||||
// per Org, and the Gitea contents endpoint is idempotent (PUT with the
|
||||
// existing SHA succeeds with no diff). This keeps the controller image
|
||||
// minimal — alpine + the binary, no git/openssh.
|
||||
//
|
||||
// Endpoints used:
|
||||
//
|
||||
// GET /api/v1/repos/{owner}/{repo}/contents/{path}
|
||||
// POST /api/v1/repos/{owner}/{repo}/contents/{path} (create file)
|
||||
// PUT /api/v1/repos/{owner}/{repo}/contents/{path} (update file)
|
||||
//
|
||||
// Per Gitea API: the response includes a `sha` field on read; the same
|
||||
// `sha` must be passed on update for optimistic locking. Create has no
|
||||
// `sha`. We surface a single PutFile that picks POST/PUT based on
|
||||
// whether the file exists.
|
||||
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileContent is the relevant subset of Gitea's ContentsResponse.
|
||||
type FileContent struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SHA string `json:"sha"`
|
||||
Type string `json:"type"` // "file" | "dir" | "symlink" | "submodule"
|
||||
Content string `json:"content,omitempty"` // base64 when Type=file
|
||||
}
|
||||
|
||||
type contentsCreateUpdate struct {
|
||||
Message string `json:"message"`
|
||||
Content string `json:"content"` // base64
|
||||
Branch string `json:"branch,omitempty"`
|
||||
NewBranch string `json:"new_branch,omitempty"`
|
||||
SHA string `json:"sha,omitempty"`
|
||||
CommitterName string `json:"-"`
|
||||
}
|
||||
|
||||
// GetFile fetches the file at path on the given branch. Returns
|
||||
// ErrFileNotFound on 404. Decodes the base64 content for the caller.
|
||||
func (c *Client) GetFile(ctx context.Context, owner, repo, path, branch string) (FileContent, []byte, error) {
|
||||
u := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s",
|
||||
c.addr, owner, repo, strings.TrimLeft(path, "/"))
|
||||
if branch != "" {
|
||||
u += "?ref=" + branch
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return FileContent{}, nil, err
|
||||
}
|
||||
c.auth(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return FileContent{}, nil, fmt.Errorf("gitea: GET contents: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
var f FileContent
|
||||
if err := json.Unmarshal(body, &f); err != nil {
|
||||
return FileContent{}, nil, fmt.Errorf("gitea: decode contents: %w", err)
|
||||
}
|
||||
if f.Type != "file" {
|
||||
return f, nil, fmt.Errorf("gitea: GET contents: path is %q not file", f.Type)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(f.Content)
|
||||
if err != nil {
|
||||
return f, nil, fmt.Errorf("gitea: decode base64 contents: %w", err)
|
||||
}
|
||||
return f, raw, nil
|
||||
case http.StatusNotFound:
|
||||
return FileContent{}, nil, ErrFileNotFound
|
||||
default:
|
||||
return FileContent{}, nil, fmt.Errorf("gitea: GET contents %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrFileNotFound is returned by GetFile when the path does not exist
|
||||
// in the repo's branch.
|
||||
var ErrFileNotFound = errors.New("gitea: file not found")
|
||||
|
||||
// PutFile creates the file if absent or updates it if present. Returns
|
||||
// the new FileContent (with the post-write SHA). If the existing
|
||||
// content matches `data` byte-for-byte, the call returns the existing
|
||||
// FileContent without issuing a write — this is what makes the
|
||||
// reconciler idempotent on a steady-state CR.
|
||||
func (c *Client) PutFile(ctx context.Context, owner, repo, path, branch string, data []byte, message string) (FileContent, error) {
|
||||
existing, currentBytes, err := c.GetFile(ctx, owner, repo, path, branch)
|
||||
if err != nil && !errors.Is(err, ErrFileNotFound) {
|
||||
return FileContent{}, fmt.Errorf("gitea.PutFile: get: %w", err)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// File exists. If contents match exactly, no-op.
|
||||
if bytes.Equal(currentBytes, data) {
|
||||
return existing, nil
|
||||
}
|
||||
// Update via PUT with the existing SHA.
|
||||
return c.contentsWrite(ctx, http.MethodPut, owner, repo, path, branch, existing.SHA, data, message)
|
||||
}
|
||||
// File absent — create via POST.
|
||||
return c.contentsWrite(ctx, http.MethodPost, owner, repo, path, branch, "", data, message)
|
||||
}
|
||||
|
||||
func (c *Client) contentsWrite(ctx context.Context, method, owner, repo, path, branch, sha string, data []byte, message string) (FileContent, error) {
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("controller: write %s", path)
|
||||
}
|
||||
body, err := json.Marshal(contentsCreateUpdate{
|
||||
Message: message,
|
||||
Content: base64.StdEncoding.EncodeToString(data),
|
||||
Branch: branch,
|
||||
SHA: sha,
|
||||
})
|
||||
if err != nil {
|
||||
return FileContent{}, err
|
||||
}
|
||||
u := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s",
|
||||
c.addr, owner, repo, strings.TrimLeft(path, "/"))
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return FileContent{}, err
|
||||
}
|
||||
c.auth(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return FileContent{}, fmt.Errorf("gitea: %s contents: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
// Gitea wraps the file inside `content` on write responses.
|
||||
var wrapped struct {
|
||||
Content FileContent `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &wrapped); err != nil {
|
||||
// Some Gitea versions return the FileContent directly.
|
||||
var direct FileContent
|
||||
if jerr := json.Unmarshal(respBody, &direct); jerr == nil {
|
||||
return direct, nil
|
||||
}
|
||||
return FileContent{}, fmt.Errorf("gitea: decode write response: %w", err)
|
||||
}
|
||||
return wrapped.Content, nil
|
||||
default:
|
||||
return FileContent{}, fmt.Errorf("gitea: %s contents %d: %s", method, resp.StatusCode, respBody)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user