All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
CI / Woodpecker: - Add explicit depends_on to all .woodpecker.yml steps (rdev + templates) - Fix skip_tls_verify -> skip-tls-verify (correct Kaniko flag name) - Add replicasets get/list to deployer RBAC for rollout status - Skeleton template: add failure:ignore on docs steps, Traefik TLS annotations on ingress, depends_on on verify step Component templates: - Fix container name in deploy steps (PROJECT_NAME-COMPONENT_NAME) - Replace kubectl scale with kubectl patch for replicas - Add post-deploy image verification and rollout status checks - Applied consistently across all 5 component templates Adapters: - gitea: Add HTTP client timeout (30s), context cancellation checks, handle 404 on GetRepo/DeleteRepo - zot: Add retry with exponential backoff (doWithRetry), limit response body reads to 10MB - cockroach: Use net.JoinHostPort for IPv6-safe DSN construction - woodpecker: Fix error wrapping (%v -> %w) - redis: Fix error wrapping (%v -> %w) - deployer: Add context cancellation checks Services: - apikey_service: Fix error wrapping (%v -> %w) - component_deploy: Fix error wrapping (%v -> %w) - project_infra: Fix error wrapping (%v -> %w) - webhook/dispatcher: Fix error wrapping (%v -> %w) Other: - CLAUDE.md: Add guide links for Gitea, Go 1.25, Woodpecker v3, Traefik v3, Zot registry - circuitbreaker: Add test for error wrapping - docs: Update deployment, troubleshooting, and runbook docs - health: Fix error wrapping (%v -> %w) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
453 lines
12 KiB
Go
453 lines
12 KiB
Go
// Package gitea provides a Gitea API adapter implementing port.GitRepository.
|
|
//
|
|
// Context Propagation Note:
|
|
// The Gitea Go SDK (code.gitea.io/sdk/gitea) does not natively support context
|
|
// propagation for HTTP requests. Methods accept context.Context for interface
|
|
// compatibility and future-proofing, but the underlying SDK calls do not use it
|
|
// for cancellation or timeouts. If cancellation is critical, consider using a
|
|
// context-aware HTTP transport or wrapping calls with context deadline checks.
|
|
//
|
|
// TODO: Fix Gitea ALLOWED_HOST_LIST — set to "private,loopback" in Gitea app.ini
|
|
// to allow webhook delivery to cluster-internal services (Woodpecker). The default
|
|
// "external" blocks delivery to internal URLs, likely causing silent webhook failures.
|
|
// This is a cluster config change, not a code change.
|
|
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"code.gitea.io/sdk/gitea"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// Ensure Client implements GitRepository and ExternalHealthChecker.
|
|
var _ port.GitRepository = (*Client)(nil)
|
|
var _ port.ExternalHealthChecker = (*Client)(nil)
|
|
|
|
// Client is a Gitea API client adapter.
|
|
type Client struct {
|
|
client *gitea.Client
|
|
url string // Gitea server URL for health checks
|
|
defaultOwner string // default organization/user for new repos
|
|
}
|
|
|
|
// SDKClient returns the underlying Gitea SDK client.
|
|
// Used by the template provider to create files in repos.
|
|
func (c *Client) SDKClient() *gitea.Client {
|
|
return c.client
|
|
}
|
|
|
|
// NewClient creates a new Gitea client.
|
|
// url is the Gitea server URL (e.g., https://git.threesix.ai)
|
|
// token is an API access token with repo permissions
|
|
// defaultOwner is the organization or user to create repos under
|
|
func NewClient(url, token, defaultOwner string) (*Client, error) {
|
|
client, err := gitea.NewClient(url,
|
|
gitea.SetToken(token),
|
|
gitea.SetHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create gitea client: %w", err)
|
|
}
|
|
return &Client{
|
|
client: client,
|
|
url: url,
|
|
defaultOwner: defaultOwner,
|
|
}, nil
|
|
}
|
|
|
|
// CreateRepo creates a new git repository under the default owner.
|
|
func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*domain.Repo, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
opts := gitea.CreateRepoOption{
|
|
Name: name,
|
|
Description: description,
|
|
Private: private,
|
|
AutoInit: false, // Empty repo - template seeding will create all files
|
|
DefaultBranch: "main",
|
|
}
|
|
|
|
var repo *gitea.Repository
|
|
var err error
|
|
|
|
// Try to create as org repo first, fall back to user repo
|
|
repo, _, err = c.client.CreateOrgRepo(c.defaultOwner, opts)
|
|
if err != nil {
|
|
// May not be an org, try as user repo
|
|
repo, _, err = c.client.CreateRepo(opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create repo: %w", err)
|
|
}
|
|
}
|
|
|
|
return repoFromGitea(repo), nil
|
|
}
|
|
|
|
// DeleteRepo deletes a repository.
|
|
func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
resp, err := c.client.DeleteRepo(owner, name)
|
|
if err != nil {
|
|
if resp != nil && resp.StatusCode == 404 {
|
|
return nil // Already deleted
|
|
}
|
|
return fmt.Errorf("failed to delete repo %s/%s: %w", owner, name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListRepos returns all repositories for an owner.
|
|
func (c *Client) ListRepos(ctx context.Context, owner string) ([]*domain.Repo, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Try as organization first
|
|
repos, _, err := c.client.ListOrgRepos(owner, gitea.ListOrgReposOptions{
|
|
ListOptions: gitea.ListOptions{PageSize: 100},
|
|
})
|
|
if err != nil {
|
|
// Try as user
|
|
repos, _, err = c.client.ListUserRepos(owner, gitea.ListReposOptions{
|
|
ListOptions: gitea.ListOptions{PageSize: 100},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list repos for %s: %w", owner, err)
|
|
}
|
|
}
|
|
|
|
result := make([]*domain.Repo, len(repos))
|
|
for i, r := range repos {
|
|
result[i] = repoFromGitea(r)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetRepo returns a single repository.
|
|
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
repo, resp, err := c.client.GetRepo(owner, name)
|
|
if err != nil {
|
|
if resp != nil && resp.StatusCode == 404 {
|
|
return nil, fmt.Errorf("repo not found: %s/%s", owner, name)
|
|
}
|
|
return nil, fmt.Errorf("failed to get repo %s/%s: %w", owner, name, err)
|
|
}
|
|
return repoFromGitea(repo), nil
|
|
}
|
|
|
|
// AddCollaborator adds a user as collaborator to a repo.
|
|
func (c *Client) AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
var accessMode gitea.AccessMode
|
|
switch permission {
|
|
case "read":
|
|
accessMode = gitea.AccessModeRead
|
|
case "write":
|
|
accessMode = gitea.AccessModeWrite
|
|
case "admin":
|
|
accessMode = gitea.AccessModeAdmin
|
|
default:
|
|
accessMode = gitea.AccessModeRead
|
|
}
|
|
|
|
_, err := c.client.AddCollaborator(owner, repo, username, gitea.AddCollaboratorOption{
|
|
Permission: &accessMode,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add collaborator %s to %s/%s: %w", username, owner, repo, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveCollaborator removes a collaborator from a repo.
|
|
func (c *Client) RemoveCollaborator(ctx context.Context, owner, repo, username string) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
_, err := c.client.DeleteCollaborator(owner, repo, username)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove collaborator %s from %s/%s: %w", username, owner, repo, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddDeployKey adds a deploy key to a repo.
|
|
func (c *Client) AddDeployKey(ctx context.Context, owner, repo, title, publicKey string, readOnly bool) (*domain.DeployKey, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
key, _, err := c.client.CreateDeployKey(owner, repo, gitea.CreateKeyOption{
|
|
Title: title,
|
|
Key: publicKey,
|
|
ReadOnly: readOnly,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to add deploy key to %s/%s: %w", owner, repo, err)
|
|
}
|
|
return &domain.DeployKey{
|
|
ID: key.ID,
|
|
Title: key.Title,
|
|
PublicKey: key.Key,
|
|
ReadOnly: key.ReadOnly,
|
|
CreatedAt: key.Created,
|
|
}, nil
|
|
}
|
|
|
|
// DeleteDeployKey removes a deploy key from a repo.
|
|
func (c *Client) DeleteDeployKey(ctx context.Context, owner, repo string, keyID int64) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
_, err := c.client.DeleteDeployKey(owner, repo, keyID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete deploy key %d from %s/%s: %w", keyID, owner, repo, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateWebhook creates a webhook on a repository.
|
|
func (c *Client) CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) (*domain.RepoWebhook, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
hook, _, err := c.client.CreateRepoHook(owner, repo, gitea.CreateHookOption{
|
|
Type: gitea.HookTypeGitea,
|
|
Config: map[string]string{
|
|
"url": url,
|
|
"content_type": "json",
|
|
"secret": secret,
|
|
},
|
|
Events: events,
|
|
Active: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create webhook on %s/%s: %w", owner, repo, err)
|
|
}
|
|
return &domain.RepoWebhook{
|
|
ID: hook.ID,
|
|
URL: hook.Config["url"],
|
|
Secret: secret,
|
|
Events: hook.Events,
|
|
Active: hook.Active,
|
|
HookType: string(hook.Type),
|
|
}, nil
|
|
}
|
|
|
|
// DeleteWebhook removes a webhook from a repo.
|
|
func (c *Client) DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
_, err := c.client.DeleteRepoHook(owner, repo, webhookID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete webhook %d from %s/%s: %w", webhookID, owner, repo, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Check returns the health status of the Gitea server.
|
|
// Implements port.ExternalHealthChecker.
|
|
func (c *Client) Check(ctx context.Context) domain.ExternalSystemStatus {
|
|
start := time.Now()
|
|
status := domain.ExternalSystemStatus{
|
|
System: domain.ExternalSystemGit,
|
|
URL: c.url,
|
|
}
|
|
|
|
// Gitea SDK doesn't support context propagation for HTTP requests,
|
|
// but check for cancellation before making the call.
|
|
select {
|
|
case <-ctx.Done():
|
|
status.Latency = time.Since(start)
|
|
status.LastChecked = time.Now().UTC()
|
|
status.Healthy = false
|
|
status.Error = ctx.Err().Error()
|
|
return status
|
|
default:
|
|
}
|
|
|
|
// Call ListMyOrgs (lightweight, tests auth)
|
|
_, _, err := c.client.ListMyOrgs(gitea.ListOrgsOptions{
|
|
ListOptions: gitea.ListOptions{PageSize: 1},
|
|
})
|
|
status.Latency = time.Since(start)
|
|
status.LastChecked = time.Now().UTC()
|
|
|
|
if err != nil {
|
|
status.Healthy = false
|
|
status.Error = err.Error()
|
|
} else {
|
|
status.Healthy = true
|
|
status.LastHealthy = status.LastChecked
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
// ListBranches returns all branches for a repository.
|
|
func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]*domain.GitBranch, error) {
|
|
// Gitea SDK doesn't support context propagation, but check for cancellation.
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
branches, _, err := c.client.ListRepoBranches(owner, repo, gitea.ListRepoBranchesOptions{
|
|
ListOptions: gitea.ListOptions{PageSize: 100},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list branches for %s/%s: %w", owner, repo, err)
|
|
}
|
|
|
|
result := make([]*domain.GitBranch, len(branches))
|
|
for i, b := range branches {
|
|
result[i] = &domain.GitBranch{
|
|
Name: b.Name,
|
|
CommitSHA: b.Commit.ID,
|
|
Protected: b.Protected,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// CreateBranch creates a new branch from a reference (branch name or commit SHA).
|
|
func (c *Client) CreateBranch(ctx context.Context, owner, repo, branchName, fromRef string) (*domain.GitBranch, error) {
|
|
// Gitea SDK doesn't support context propagation, but check for cancellation.
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
branch, _, err := c.client.CreateBranch(owner, repo, gitea.CreateBranchOption{
|
|
BranchName: branchName,
|
|
OldBranchName: fromRef,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create branch %s from %s in %s/%s: %w", branchName, fromRef, owner, repo, err)
|
|
}
|
|
|
|
return &domain.GitBranch{
|
|
Name: branch.Name,
|
|
CommitSHA: branch.Commit.ID,
|
|
Protected: branch.Protected,
|
|
}, nil
|
|
}
|
|
|
|
// CreateAccessToken creates a new personal access token for git operations.
|
|
func (c *Client) CreateAccessToken(ctx context.Context, name string, scopes []string, expiresAt *time.Time) (*domain.GitAccessToken, error) {
|
|
// Gitea SDK doesn't support context propagation, but check for cancellation.
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Convert string scopes to Gitea AccessTokenScope
|
|
tokenScopes := make([]gitea.AccessTokenScope, len(scopes))
|
|
for i, s := range scopes {
|
|
tokenScopes[i] = gitea.AccessTokenScope(s)
|
|
}
|
|
|
|
token, _, err := c.client.CreateAccessToken(gitea.CreateAccessTokenOption{
|
|
Name: name,
|
|
Scopes: tokenScopes,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create access token: %w", err)
|
|
}
|
|
|
|
return &domain.GitAccessToken{
|
|
ID: token.ID,
|
|
Name: token.Name,
|
|
Token: token.Token,
|
|
Scopes: scopes,
|
|
}, nil
|
|
}
|
|
|
|
// DeleteAccessToken revokes and deletes an access token.
|
|
func (c *Client) DeleteAccessToken(ctx context.Context, tokenID int64) error {
|
|
// Gitea SDK doesn't support context propagation, but check for cancellation.
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
_, err := c.client.DeleteAccessToken(tokenID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete access token %d: %w", tokenID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DefaultOwner returns the default organization for repo operations.
|
|
func (c *Client) DefaultOwner() string {
|
|
return c.defaultOwner
|
|
}
|
|
|
|
// URL returns the Gitea server URL.
|
|
func (c *Client) URL() string {
|
|
return c.url
|
|
}
|
|
|
|
// repoFromGitea converts a gitea.Repository to domain.Repo.
|
|
func repoFromGitea(r *gitea.Repository) *domain.Repo {
|
|
return &domain.Repo{
|
|
ID: r.ID,
|
|
Owner: r.Owner.UserName,
|
|
Name: r.Name,
|
|
FullName: r.FullName,
|
|
Description: r.Description,
|
|
Private: r.Private,
|
|
CloneSSH: r.SSHURL,
|
|
CloneHTTP: r.CloneURL,
|
|
HTMLURL: r.HTMLURL,
|
|
CreatedAt: r.Created,
|
|
UpdatedAt: r.Updated,
|
|
}
|
|
}
|