chore: accumulated platform hardening and CI fixes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
This commit is contained in:
parent
3c9876a678
commit
a9ad3d8304
@ -1,5 +1,10 @@
|
|||||||
# Woodpecker CI for rdev platform
|
# Woodpecker CI for rdev platform
|
||||||
# Builds and deploys rdev-api, rdev-worker, and rdev-claudebox
|
# Builds and deploys rdev-api, rdev-worker, and rdev-claudebox
|
||||||
|
#
|
||||||
|
# TODO: Remove skip-tls-verify from Kaniko steps once cert verification is tested.
|
||||||
|
# Registry has valid LE cert via Traefik — Kaniko should be able to verify it.
|
||||||
|
# Test by removing from one step first. If Kaniko can't verify (runs inside
|
||||||
|
# cluster hitting internal service), mount the CA bundle instead.
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &when_main
|
- &when_main
|
||||||
@ -10,6 +15,7 @@ steps:
|
|||||||
# Run tests first
|
# Run tests first
|
||||||
test:
|
test:
|
||||||
image: golang:1.25-alpine
|
image: golang:1.25-alpine
|
||||||
|
depends_on: []
|
||||||
commands:
|
commands:
|
||||||
- apk add --no-cache git
|
- apk add --no-cache git
|
||||||
- go test ./...
|
- go test ./...
|
||||||
@ -17,6 +23,7 @@ steps:
|
|||||||
# Build rdev-api image
|
# Build rdev-api image
|
||||||
build-api:
|
build-api:
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
|
depends_on: [test]
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: rdev/api
|
repo: rdev/api
|
||||||
@ -26,13 +33,14 @@ steps:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.api
|
dockerfile: Dockerfile.api
|
||||||
cache: true
|
cache: true
|
||||||
skip_tls_verify: true
|
skip-tls-verify: true
|
||||||
when:
|
when:
|
||||||
<<: *when_main
|
<<: *when_main
|
||||||
|
|
||||||
# Build rdev-worker image
|
# Build rdev-worker image
|
||||||
build-worker:
|
build-worker:
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
|
depends_on: [test]
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: rdev/worker
|
repo: rdev/worker
|
||||||
@ -42,13 +50,14 @@ steps:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.worker
|
dockerfile: Dockerfile.worker
|
||||||
cache: true
|
cache: true
|
||||||
skip_tls_verify: true
|
skip-tls-verify: true
|
||||||
when:
|
when:
|
||||||
<<: *when_main
|
<<: *when_main
|
||||||
|
|
||||||
# Build rdev-claudebox image
|
# Build rdev-claudebox image
|
||||||
build-claudebox:
|
build-claudebox:
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
|
depends_on: [test]
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: rdev/claudebox
|
repo: rdev/claudebox
|
||||||
@ -58,13 +67,14 @@ steps:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
cache: true
|
cache: true
|
||||||
skip_tls_verify: true
|
skip-tls-verify: true
|
||||||
when:
|
when:
|
||||||
<<: *when_main
|
<<: *when_main
|
||||||
|
|
||||||
# Deploy to k3s cluster
|
# Deploy to k3s cluster
|
||||||
deploy:
|
deploy:
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
|
depends_on: [build-api, build-worker, build-claudebox]
|
||||||
commands:
|
commands:
|
||||||
- echo "Deploying rdev-api..."
|
- echo "Deploying rdev-api..."
|
||||||
- kubectl set image deployment/rdev-api rdev-api=registry.threesix.ai/rdev/api:${CI_COMMIT_SHA:0:8} -n rdev
|
- kubectl set image deployment/rdev-api rdev-api=registry.threesix.ai/rdev/api:${CI_COMMIT_SHA:0:8} -n rdev
|
||||||
|
|||||||
@ -50,6 +50,11 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
|
|||||||
| **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) |
|
| **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) |
|
||||||
| **Visual verification (Playwright)** | [services/visual-verification.md](.claude/guides/services/visual-verification.md) |
|
| **Visual verification (Playwright)** | [services/visual-verification.md](.claude/guides/services/visual-verification.md) |
|
||||||
| **Interactive remote development** | [services/interactive-remote-dev.md](.claude/guides/services/interactive-remote-dev.md) |
|
| **Interactive remote development** | [services/interactive-remote-dev.md](.claude/guides/services/interactive-remote-dev.md) |
|
||||||
|
| **Gitea 1.22 / SDK / webhooks** | [ops/gitea-1.22.md](.claude/guides/ops/gitea-1.22.md) |
|
||||||
|
| **Go 1.25 features & migration** | [backend/go-1.25.md](.claude/guides/backend/go-1.25.md) |
|
||||||
|
| **Woodpecker CI v3 pipelines** | [ops/woodpecker-v3.md](.claude/guides/ops/woodpecker-v3.md) |
|
||||||
|
| **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) |
|
||||||
|
| **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) |
|
||||||
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
|
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
steps:
|
steps:
|
||||||
test:
|
test:
|
||||||
image: golang:1.22-alpine
|
image: golang:1.25-alpine
|
||||||
commands:
|
commands:
|
||||||
- go test ./...
|
- go test ./...
|
||||||
when:
|
when:
|
||||||
- event: [push, pull_request]
|
- event: [push, pull_request]
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: golang:1.22-alpine
|
image: golang:1.25-alpine
|
||||||
commands:
|
commands:
|
||||||
- go build -o app ./cmd/api
|
- go build -o app ./cmd/api
|
||||||
when:
|
when:
|
||||||
|
|||||||
@ -21,13 +21,16 @@ metadata:
|
|||||||
app.kubernetes.io/name: woodpecker-deployer
|
app.kubernetes.io/name: woodpecker-deployer
|
||||||
app.kubernetes.io/part-of: rdev
|
app.kubernetes.io/part-of: rdev
|
||||||
rules:
|
rules:
|
||||||
# Minimal permissions for `kubectl set image` on deployments
|
# Deploy steps: set image, patch replicas, verify rollout
|
||||||
# - get: Required to read current deployment state
|
# - get/list: read deployment and replicaset state
|
||||||
# - list: Required for kubectl to find the deployment
|
# - patch: kubectl set image, kubectl patch (replicas)
|
||||||
# - patch: Required for `kubectl set image` to update the container image
|
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
verbs: ["get", "list", "patch"]
|
verbs: ["get", "list", "patch"]
|
||||||
|
# rollout status needs to watch replicasets
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["replicasets"]
|
||||||
|
verbs: ["get", "list"]
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
|
|||||||
@ -184,10 +184,10 @@ metadata:
|
|||||||
name: rdev-api
|
name: rdev-api
|
||||||
namespace: rdev
|
namespace: rdev
|
||||||
annotations:
|
annotations:
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
- host: rdev.example.com
|
- host: rdev.example.com
|
||||||
http:
|
http:
|
||||||
|
|||||||
@ -101,10 +101,18 @@ kubectl -n rdev logs -l app=rdev-api --since=10m | \
|
|||||||
|
|
||||||
### If Under Attack
|
### If Under Attack
|
||||||
|
|
||||||
1. **Immediate**: Block at ingress
|
1. **Immediate**: Block at ingress using Traefik ipAllowList Middleware
|
||||||
```yaml
|
```yaml
|
||||||
# Add to ingress annotations
|
# Use Traefik ipAllowList Middleware CRD instead:
|
||||||
nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,192.168.0.0/16"
|
# apiVersion: traefik.io/v1alpha1
|
||||||
|
# kind: Middleware
|
||||||
|
# metadata:
|
||||||
|
# name: internal-only
|
||||||
|
# spec:
|
||||||
|
# ipAllowList:
|
||||||
|
# sourceRange:
|
||||||
|
# - "10.0.0.0/8"
|
||||||
|
# - "192.168.0.0/16"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Short-term**: Increase rate limits
|
2. **Short-term**: Increase rate limits
|
||||||
|
|||||||
@ -75,9 +75,11 @@ kubectl -n rdev patch deployment rdev-api --type='json' -p='[
|
|||||||
kubectl -n rdev logs -l app=rdev-api | grep "SSE connection" | tail -50
|
kubectl -n rdev logs -l app=rdev-api | grep "SSE connection" | tail -50
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Reduce connection timeout in ingress:
|
2. Reduce connection timeout at the Traefik entrypoint level:
|
||||||
```yaml
|
```yaml
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800" # 30 min max
|
# Traefik: configure respondingTimeouts at entrypoint level
|
||||||
|
# or use ServersTransport for per-service forwarding timeout
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
```
|
```
|
||||||
|
|
||||||
### If Command Output Is Too Large
|
### If Command Output Is Too Large
|
||||||
|
|||||||
@ -201,9 +201,13 @@ kubectl -n ingress-nginx get ing rdev-api -o yaml
|
|||||||
**Common Causes:**
|
**Common Causes:**
|
||||||
|
|
||||||
1. **Proxy timeout:**
|
1. **Proxy timeout:**
|
||||||
Ensure ingress has long timeout:
|
Traefik timeout is configured at the entrypoint level via HelmChartConfig,
|
||||||
|
not per-Ingress annotations. See `.claude/guides/ops/traefik-v3.md` for details.
|
||||||
```yaml
|
```yaml
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
# Traefik timeout is configured at the entrypoint level via HelmChartConfig
|
||||||
|
# See .claude/guides/ops/traefik-v3.md for details
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Client timeout:**
|
2. **Client timeout:**
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -39,8 +41,9 @@ func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) {
|
|||||||
cfg.SSLMode = "disable"
|
cfg.SSLMode = "disable"
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := fmt.Sprintf("postgresql://%s@%s:%d/defaultdb?sslmode=%s",
|
hostPort := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
||||||
cfg.User, cfg.Host, cfg.Port, cfg.SSLMode)
|
dsn := fmt.Sprintf("postgresql://%s@%s/defaultdb?sslmode=%s",
|
||||||
|
cfg.User, hostPort, cfg.SSLMode)
|
||||||
|
|
||||||
db, err := sql.Open("postgres", dsn)
|
db, err := sql.Open("postgres", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -112,8 +115,9 @@ func (p *Provisioner) CreateProjectDatabase(ctx context.Context, projectID strin
|
|||||||
|
|
||||||
// Build connection URL
|
// Build connection URL
|
||||||
// In insecure mode, password is not used in connection, but we store it for future TLS migration
|
// In insecure mode, password is not used in connection, but we store it for future TLS migration
|
||||||
url := fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=disable",
|
hostPort := net.JoinHostPort(p.host, strconv.Itoa(p.port))
|
||||||
username, p.host, p.port, dbName)
|
url := fmt.Sprintf("postgresql://%s@%s/%s?sslmode=disable",
|
||||||
|
username, hostPort, dbName)
|
||||||
|
|
||||||
p.logger.Info("created project database",
|
p.logger.Info("created project database",
|
||||||
"project_id", projectID,
|
"project_id", projectID,
|
||||||
@ -179,8 +183,9 @@ func (p *Provisioner) GetProjectDatabase(ctx context.Context, projectID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Database exists; construct credentials without password
|
// Database exists; construct credentials without password
|
||||||
url := fmt.Sprintf("postgresql://%s@%s:%d/%s?sslmode=disable",
|
hostPort := net.JoinHostPort(p.host, strconv.Itoa(p.port))
|
||||||
username, p.host, p.port, dbName)
|
url := fmt.Sprintf("postgresql://%s@%s/%s?sslmode=disable",
|
||||||
|
username, hostPort, dbName)
|
||||||
|
|
||||||
return &domain.DatabaseCredentials{
|
return &domain.DatabaseCredentials{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
|
|||||||
@ -262,6 +262,8 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
|
|||||||
if d.config.TLSIssuer != "" {
|
if d.config.TLSIssuer != "" {
|
||||||
annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer
|
annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer
|
||||||
}
|
}
|
||||||
|
annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure"
|
||||||
|
annotations["traefik.ingress.kubernetes.io/router.tls"] = "true"
|
||||||
|
|
||||||
ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations)
|
ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations)
|
||||||
|
|
||||||
|
|||||||
@ -118,6 +118,8 @@ func (d *Deployer) createUnifiedIngress(ctx context.Context, projectName, host,
|
|||||||
if d.config.TLSIssuer != "" {
|
if d.config.TLSIssuer != "" {
|
||||||
annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer
|
annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer
|
||||||
}
|
}
|
||||||
|
annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure"
|
||||||
|
annotations["traefik.ingress.kubernetes.io/router.tls"] = "true"
|
||||||
|
|
||||||
ingress := &networkingv1.Ingress{
|
ingress := &networkingv1.Ingress{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
|||||||
@ -6,11 +6,17 @@
|
|||||||
// compatibility and future-proofing, but the underlying SDK calls do not use it
|
// compatibility and future-proofing, but the underlying SDK calls do not use it
|
||||||
// for cancellation or timeouts. If cancellation is critical, consider using a
|
// for cancellation or timeouts. If cancellation is critical, consider using a
|
||||||
// context-aware HTTP transport or wrapping calls with context deadline checks.
|
// 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
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
@ -40,7 +46,10 @@ func (c *Client) SDKClient() *gitea.Client {
|
|||||||
// token is an API access token with repo permissions
|
// token is an API access token with repo permissions
|
||||||
// defaultOwner is the organization or user to create repos under
|
// defaultOwner is the organization or user to create repos under
|
||||||
func NewClient(url, token, defaultOwner string) (*Client, error) {
|
func NewClient(url, token, defaultOwner string) (*Client, error) {
|
||||||
client, err := gitea.NewClient(url, gitea.SetToken(token))
|
client, err := gitea.NewClient(url,
|
||||||
|
gitea.SetToken(token),
|
||||||
|
gitea.SetHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create gitea client: %w", err)
|
return nil, fmt.Errorf("failed to create gitea client: %w", err)
|
||||||
}
|
}
|
||||||
@ -53,6 +62,12 @@ func NewClient(url, token, defaultOwner string) (*Client, error) {
|
|||||||
|
|
||||||
// CreateRepo creates a new git repository under the default owner.
|
// 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) {
|
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{
|
opts := gitea.CreateRepoOption{
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: description,
|
Description: description,
|
||||||
@ -79,8 +94,17 @@ func (c *Client) CreateRepo(ctx context.Context, name, description string, priva
|
|||||||
|
|
||||||
// DeleteRepo deletes a repository.
|
// DeleteRepo deletes a repository.
|
||||||
func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error {
|
func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error {
|
||||||
_, err := c.client.DeleteRepo(owner, name)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.DeleteRepo(owner, name)
|
||||||
if err != nil {
|
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 fmt.Errorf("failed to delete repo %s/%s: %w", owner, name, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -88,6 +112,12 @@ func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error {
|
|||||||
|
|
||||||
// ListRepos returns all repositories for an owner.
|
// ListRepos returns all repositories for an owner.
|
||||||
func (c *Client) ListRepos(ctx context.Context, owner string) ([]*domain.Repo, error) {
|
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
|
// Try as organization first
|
||||||
repos, _, err := c.client.ListOrgRepos(owner, gitea.ListOrgReposOptions{
|
repos, _, err := c.client.ListOrgRepos(owner, gitea.ListOrgReposOptions{
|
||||||
ListOptions: gitea.ListOptions{PageSize: 100},
|
ListOptions: gitea.ListOptions{PageSize: 100},
|
||||||
@ -111,8 +141,17 @@ func (c *Client) ListRepos(ctx context.Context, owner string) ([]*domain.Repo, e
|
|||||||
|
|
||||||
// GetRepo returns a single repository.
|
// GetRepo returns a single repository.
|
||||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo, error) {
|
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo, error) {
|
||||||
repo, _, err := c.client.GetRepo(owner, name)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, resp, err := c.client.GetRepo(owner, name)
|
||||||
if err != nil {
|
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 nil, fmt.Errorf("failed to get repo %s/%s: %w", owner, name, err)
|
||||||
}
|
}
|
||||||
return repoFromGitea(repo), nil
|
return repoFromGitea(repo), nil
|
||||||
@ -120,6 +159,12 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*domain.Repo,
|
|||||||
|
|
||||||
// AddCollaborator adds a user as collaborator to a repo.
|
// AddCollaborator adds a user as collaborator to a repo.
|
||||||
func (c *Client) AddCollaborator(ctx context.Context, owner, repo, username string, permission string) error {
|
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
|
var accessMode gitea.AccessMode
|
||||||
switch permission {
|
switch permission {
|
||||||
case "read":
|
case "read":
|
||||||
@ -143,6 +188,12 @@ func (c *Client) AddCollaborator(ctx context.Context, owner, repo, username stri
|
|||||||
|
|
||||||
// RemoveCollaborator removes a collaborator from a repo.
|
// RemoveCollaborator removes a collaborator from a repo.
|
||||||
func (c *Client) RemoveCollaborator(ctx context.Context, owner, repo, username string) error {
|
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)
|
_, err := c.client.DeleteCollaborator(owner, repo, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove collaborator %s from %s/%s: %w", username, owner, repo, err)
|
return fmt.Errorf("failed to remove collaborator %s from %s/%s: %w", username, owner, repo, err)
|
||||||
@ -152,6 +203,12 @@ func (c *Client) RemoveCollaborator(ctx context.Context, owner, repo, username s
|
|||||||
|
|
||||||
// AddDeployKey adds a deploy key to a repo.
|
// 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) {
|
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{
|
key, _, err := c.client.CreateDeployKey(owner, repo, gitea.CreateKeyOption{
|
||||||
Title: title,
|
Title: title,
|
||||||
Key: publicKey,
|
Key: publicKey,
|
||||||
@ -171,6 +228,12 @@ func (c *Client) AddDeployKey(ctx context.Context, owner, repo, title, publicKey
|
|||||||
|
|
||||||
// DeleteDeployKey removes a deploy key from a repo.
|
// DeleteDeployKey removes a deploy key from a repo.
|
||||||
func (c *Client) DeleteDeployKey(ctx context.Context, owner, repo string, keyID int64) error {
|
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)
|
_, err := c.client.DeleteDeployKey(owner, repo, keyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete deploy key %d from %s/%s: %w", keyID, owner, repo, err)
|
return fmt.Errorf("failed to delete deploy key %d from %s/%s: %w", keyID, owner, repo, err)
|
||||||
@ -180,6 +243,12 @@ func (c *Client) DeleteDeployKey(ctx context.Context, owner, repo string, keyID
|
|||||||
|
|
||||||
// CreateWebhook creates a webhook on a repository.
|
// CreateWebhook creates a webhook on a repository.
|
||||||
func (c *Client) CreateWebhook(ctx context.Context, owner, repo, url, secret string, events []string) (*domain.RepoWebhook, error) {
|
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{
|
hook, _, err := c.client.CreateRepoHook(owner, repo, gitea.CreateHookOption{
|
||||||
Type: gitea.HookTypeGitea,
|
Type: gitea.HookTypeGitea,
|
||||||
Config: map[string]string{
|
Config: map[string]string{
|
||||||
@ -205,6 +274,12 @@ func (c *Client) CreateWebhook(ctx context.Context, owner, repo, url, secret str
|
|||||||
|
|
||||||
// DeleteWebhook removes a webhook from a repo.
|
// DeleteWebhook removes a webhook from a repo.
|
||||||
func (c *Client) DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error {
|
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)
|
_, err := c.client.DeleteRepoHook(owner, repo, webhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete webhook %d from %s/%s: %w", webhookID, owner, repo, err)
|
return fmt.Errorf("failed to delete webhook %d from %s/%s: %w", webhookID, owner, repo, err)
|
||||||
|
|||||||
@ -107,6 +107,8 @@ func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOpt
|
|||||||
if m.config.TLSIssuer != "" {
|
if m.config.TLSIssuer != "" {
|
||||||
annotations["cert-manager.io/cluster-issuer"] = m.config.TLSIssuer
|
annotations["cert-manager.io/cluster-issuer"] = m.config.TLSIssuer
|
||||||
}
|
}
|
||||||
|
annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure"
|
||||||
|
annotations["traefik.ingress.kubernetes.io/router.tls"] = "true"
|
||||||
|
|
||||||
ingress := &networkingv1.Ingress{
|
ingress := &networkingv1.Ingress{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -40,7 +42,7 @@ func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := redis.NewClient(&redis.Options{
|
client := redis.NewClient(&redis.Options{
|
||||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||||
Password: cfg.Password,
|
Password: cfg.Password,
|
||||||
DB: 0,
|
DB: 0,
|
||||||
})
|
})
|
||||||
@ -114,7 +116,7 @@ func (p *Provisioner) CreateProjectCache(ctx context.Context, projectID string)
|
|||||||
"username", username,
|
"username", username,
|
||||||
"prefix", prefix)
|
"prefix", prefix)
|
||||||
|
|
||||||
url := fmt.Sprintf("redis://%s:%s@%s:%d", username, password, p.host, p.port)
|
url := fmt.Sprintf("redis://%s:%s@%s", username, password, net.JoinHostPort(p.host, strconv.Itoa(p.port)))
|
||||||
return &domain.CacheCredentials{
|
return &domain.CacheCredentials{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
URL: url,
|
URL: url,
|
||||||
|
|||||||
@ -23,7 +23,6 @@ steps:
|
|||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
cache: true
|
cache: true
|
||||||
skip-tls-verify: true
|
skip-tls-verify: true
|
||||||
failure: ignore
|
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|||||||
@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}:
|
|||||||
depends_on: [verify-{{COMPONENT_NAME}}]
|
depends_on: [verify-{{COMPONENT_NAME}}]
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
- echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}"
|
||||||
- kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true
|
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
|
||||||
|
- kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}'
|
||||||
|
- |
|
||||||
|
echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}"
|
||||||
|
ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||||
|
EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}"
|
||||||
|
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||||
|
echo "FATAL: Image mismatch after deploy"
|
||||||
|
echo " expected: $EXPECTED_IMAGE"
|
||||||
|
echo " actual: $ACTUAL_IMAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||||
|
echo "==> Waiting for rollout (timeout 120s)..."
|
||||||
|
kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s
|
||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|||||||
@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}:
|
|||||||
depends_on: [verify-{{COMPONENT_NAME}}]
|
depends_on: [verify-{{COMPONENT_NAME}}]
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
- echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}"
|
||||||
- kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true
|
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
|
||||||
|
- kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}'
|
||||||
|
- |
|
||||||
|
echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}"
|
||||||
|
ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||||
|
EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}"
|
||||||
|
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||||
|
echo "FATAL: Image mismatch after deploy"
|
||||||
|
echo " expected: $EXPECTED_IMAGE"
|
||||||
|
echo " actual: $ACTUAL_IMAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||||
|
echo "==> Waiting for rollout (timeout 120s)..."
|
||||||
|
kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s
|
||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|||||||
@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}:
|
|||||||
depends_on: [verify-{{COMPONENT_NAME}}]
|
depends_on: [verify-{{COMPONENT_NAME}}]
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
- echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}"
|
||||||
- kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true
|
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
|
||||||
|
- kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}'
|
||||||
|
- |
|
||||||
|
echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}"
|
||||||
|
ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||||
|
EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}"
|
||||||
|
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||||
|
echo "FATAL: Image mismatch after deploy"
|
||||||
|
echo " expected: $EXPECTED_IMAGE"
|
||||||
|
echo " actual: $ACTUAL_IMAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||||
|
echo "==> Waiting for rollout (timeout 120s)..."
|
||||||
|
kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s
|
||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|||||||
@ -55,8 +55,22 @@ deploy-{{COMPONENT_NAME}}:
|
|||||||
depends_on: [verify-{{COMPONENT_NAME}}]
|
depends_on: [verify-{{COMPONENT_NAME}}]
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
- echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}"
|
||||||
- kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true
|
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
|
||||||
|
- kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}'
|
||||||
|
- |
|
||||||
|
echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}"
|
||||||
|
ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||||
|
EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}"
|
||||||
|
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||||
|
echo "FATAL: Image mismatch after deploy"
|
||||||
|
echo " expected: $EXPECTED_IMAGE"
|
||||||
|
echo " actual: $ACTUAL_IMAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||||
|
echo "==> Waiting for rollout (timeout 120s)..."
|
||||||
|
kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s
|
||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|||||||
@ -53,8 +53,22 @@ deploy-{{COMPONENT_NAME}}:
|
|||||||
depends_on: [verify-{{COMPONENT_NAME}}]
|
depends_on: [verify-{{COMPONENT_NAME}}]
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
|
- echo "==> Deploying {{COMPONENT_NAME}} with image tag ${CI_COMMIT_SHA:0:8}"
|
||||||
- kubectl scale deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} --replicas=1 -n projects 2>/dev/null || true
|
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{PROJECT_NAME}}-{{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
|
||||||
|
- kubectl patch deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -p '{"spec":{"replicas":1}}'
|
||||||
|
- |
|
||||||
|
echo "==> Verifying deployment {{PROJECT_NAME}}-{{COMPONENT_NAME}}"
|
||||||
|
ACTUAL_IMAGE=$(kubectl get deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||||
|
EXPECTED_IMAGE="registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8}"
|
||||||
|
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||||
|
echo "FATAL: Image mismatch after deploy"
|
||||||
|
echo " expected: $EXPECTED_IMAGE"
|
||||||
|
echo " actual: $ACTUAL_IMAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||||
|
echo "==> Waiting for rollout (timeout 120s)..."
|
||||||
|
kubectl rollout status deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} -n projects --timeout=120s
|
||||||
when:
|
when:
|
||||||
branch: main
|
branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|||||||
@ -9,7 +9,6 @@ steps:
|
|||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
cache: true
|
cache: true
|
||||||
skip-tls-verify: true
|
skip-tls-verify: true
|
||||||
failure: ignore
|
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|||||||
@ -23,7 +23,6 @@ steps:
|
|||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
cache: true
|
cache: true
|
||||||
skip-tls-verify: true
|
skip-tls-verify: true
|
||||||
failure: ignore
|
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
# CI/CD Pipeline for {{PROJECT_NAME}}
|
# CI/CD Pipeline for {{PROJECT_NAME}}
|
||||||
# Components will add their build steps below the marker
|
# Components will add their build steps below the marker
|
||||||
|
#
|
||||||
|
# TODO: Templatize registry URL — replace hardcoded registry.threesix.ai with
|
||||||
|
# {{REGISTRY_URL}} so the registry is configurable per environment.
|
||||||
|
|
||||||
clone:
|
clone:
|
||||||
git:
|
git:
|
||||||
@ -9,6 +12,7 @@ clone:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
deps:
|
deps:
|
||||||
|
depends_on: []
|
||||||
image: golang:1.25
|
image: golang:1.25
|
||||||
commands:
|
commands:
|
||||||
- go work sync
|
- go work sync
|
||||||
@ -112,6 +116,7 @@ steps:
|
|||||||
generate-docs:
|
generate-docs:
|
||||||
image: node:20-slim
|
image: node:20-slim
|
||||||
depends_on: [export-openapi]
|
depends_on: [export-openapi]
|
||||||
|
failure: ignore
|
||||||
commands:
|
commands:
|
||||||
- npm install -g widdershins
|
- npm install -g widdershins
|
||||||
- |
|
- |
|
||||||
@ -142,6 +147,7 @@ steps:
|
|||||||
build-docs:
|
build-docs:
|
||||||
image: ruby:3.2-slim
|
image: ruby:3.2-slim
|
||||||
depends_on: [generate-docs]
|
depends_on: [generate-docs]
|
||||||
|
failure: ignore
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
if [ ! -d "docs" ] || [ ! -f "docs/Gemfile" ]; then
|
if [ ! -d "docs" ] || [ ! -f "docs/Gemfile" ]; then
|
||||||
@ -294,6 +300,8 @@ steps:
|
|||||||
project: {{PROJECT_NAME}}
|
project: {{PROJECT_NAME}}
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
tls:
|
tls:
|
||||||
@ -319,6 +327,7 @@ steps:
|
|||||||
event: push
|
event: push
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
|
depends_on: [services-deployed]
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
commands:
|
commands:
|
||||||
- echo "Pipeline complete for {{PROJECT_NAME}}"
|
- echo "Pipeline complete for {{PROJECT_NAME}}"
|
||||||
|
|||||||
@ -176,7 +176,7 @@ func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// SDK bug: RepoLookup returns non-nil empty struct on error
|
// SDK bug: RepoLookup returns non-nil empty struct on error
|
||||||
targetRepo = nil
|
targetRepo = nil
|
||||||
lastErr = fmt.Errorf("repo not found in Woodpecker: %s", fullName)
|
lastErr = fmt.Errorf("repo not found in Woodpecker %s: %w", fullName, err)
|
||||||
if attempt < maxAttempts {
|
if attempt < maxAttempts {
|
||||||
c.logger.Debug("repo not found, retrying", "repo", fullName, "attempt", attempt, "max", maxAttempts)
|
c.logger.Debug("repo not found, retrying", "repo", fullName, "attempt", attempt, "max", maxAttempts)
|
||||||
time.Sleep(retryDelay)
|
time.Sleep(retryDelay)
|
||||||
@ -245,7 +245,7 @@ func (c *Client) DeactivateRepo(ctx context.Context, owner, repo string) error {
|
|||||||
// Find the repo
|
// Find the repo
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("repo not found: %s", fullName)
|
return fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate (remove from Woodpecker)
|
// Deactivate (remove from Woodpecker)
|
||||||
@ -269,7 +269,7 @@ func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*domain.CIRep
|
|||||||
|
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("repo not found: %s", fullName)
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return repoFromWoodpecker(r), nil
|
return repoFromWoodpecker(r), nil
|
||||||
@ -310,7 +310,7 @@ func (c *Client) AddSecret(ctx context.Context, owner, repo string, secret domai
|
|||||||
// Find the repo to get its ID
|
// Find the repo to get its ID
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("repo not found: %s", fullName)
|
return fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the secret
|
// Create the secret
|
||||||
@ -341,7 +341,7 @@ func (c *Client) DeleteSecret(ctx context.Context, owner, repo, secretName strin
|
|||||||
// Find the repo to get its ID
|
// Find the repo to get its ID
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("repo not found: %s", fullName)
|
return fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the secret
|
// Delete the secret
|
||||||
|
|||||||
@ -24,7 +24,7 @@ func (c *Client) ListPipelines(ctx context.Context, owner, repo string) ([]*doma
|
|||||||
|
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("repo not found: %s", fullName)
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{})
|
pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{})
|
||||||
@ -51,7 +51,7 @@ func (c *Client) GetPipeline(ctx context.Context, owner, repo string, number int
|
|||||||
|
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("repo not found: %s", fullName)
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := c.client.Pipeline(r.ID, number)
|
p, err := c.client.Pipeline(r.ID, number)
|
||||||
@ -75,7 +75,7 @@ func (c *Client) GetPipelineSteps(ctx context.Context, owner, repo string, numbe
|
|||||||
|
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("repo not found: %s", fullName)
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := c.client.Pipeline(r.ID, number)
|
p, err := c.client.Pipeline(r.ID, number)
|
||||||
@ -150,7 +150,7 @@ func (c *Client) TriggerBuild(ctx context.Context, owner, repo, branch string) (
|
|||||||
|
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("repo not found: %s", fullName)
|
return 0, fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new pipeline for the branch (with circuit breaker protection)
|
// Create a new pipeline for the branch (with circuit breaker protection)
|
||||||
@ -182,7 +182,7 @@ func (c *Client) RetryPipeline(ctx context.Context, owner, repo string, number i
|
|||||||
|
|
||||||
r, err := c.client.RepoLookup(fullName)
|
r, err := c.client.RepoLookup(fullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("repo not found: %s", fullName)
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart the pipeline using PipelineStart (with circuit breaker protection)
|
// Restart the pipeline using PipelineStart (with circuit breaker protection)
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
// Package zot provides a client for interacting with the zot container registry.
|
// Package zot provides a client for interacting with the zot container registry.
|
||||||
|
//
|
||||||
|
// TODO: Deploy recommended Zot config with gcInterval, retention policies, and
|
||||||
|
// deduplication. Current live config has no periodic GC — old tags accumulate
|
||||||
|
// until disk fills. Add Zot manifests to deployments/k8s/base/zot/ for version
|
||||||
|
// control. See .claude/guides/ops/zot-registry.md for the recommended config.
|
||||||
package zot
|
package zot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -43,6 +48,45 @@ func (c *Client) WithLogger(logger *slog.Logger) *Client {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxResponseBodySize is the maximum response body size (10MB) to prevent OOM on large responses.
|
||||||
|
const maxResponseBodySize = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// doWithRetry executes an HTTP request with up to 3 attempts and exponential backoff.
|
||||||
|
// It retries on network errors and 5xx status codes, but NOT on 4xx client errors.
|
||||||
|
//
|
||||||
|
// NOTE: This assumes the request has no body (GET, HEAD, DELETE) since the body
|
||||||
|
// cannot be re-read on retry. If POST/PUT support is needed, the caller must
|
||||||
|
// provide a body factory or buffer the body for re-use.
|
||||||
|
func (c *Client) doWithRetry(req *http.Request) (*http.Response, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||||
|
c.logger.Warn("retrying registry request",
|
||||||
|
"attempt", attempt+1,
|
||||||
|
"backoff", backoff,
|
||||||
|
"method", req.Method,
|
||||||
|
"url", req.URL.String(),
|
||||||
|
)
|
||||||
|
time.Sleep(backoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize))
|
||||||
|
resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("registry returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("registry request failed after 3 attempts: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
// Check returns the health status of the registry.
|
// Check returns the health status of the registry.
|
||||||
// A 200 or 401 response indicates the registry is healthy (401 means auth required but registry is up).
|
// A 200 or 401 response indicates the registry is healthy (401 means auth required but registry is up).
|
||||||
func (c *Client) Check(ctx context.Context) domain.RegistryStatus {
|
func (c *Client) Check(ctx context.Context) domain.RegistryStatus {
|
||||||
@ -58,7 +102,7 @@ func (c *Client) Check(ctx context.Context) domain.RegistryStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.doWithRetry(req)
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -107,7 +151,7 @@ func (c *Client) ListRepositories(ctx context.Context) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("create request: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.doWithRetry(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
@ -117,7 +161,7 @@ func (c *Client) ListRepositories(ctx context.Context) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read body: %w", err)
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
}
|
}
|
||||||
@ -194,7 +238,7 @@ func (c *Client) listTags(ctx context.Context, repo string) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("create request: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.doWithRetry(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
@ -207,7 +251,7 @@ func (c *Client) listTags(ctx context.Context, repo string) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read body: %w", err)
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
}
|
}
|
||||||
@ -229,7 +273,7 @@ func (c *Client) deleteManifest(ctx context.Context, repo, tag string) error {
|
|||||||
}
|
}
|
||||||
headReq.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
|
headReq.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
|
||||||
|
|
||||||
headResp, err := c.httpClient.Do(headReq)
|
headResp, err := c.doWithRetry(headReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("head request failed: %w", err)
|
return fmt.Errorf("head request failed: %w", err)
|
||||||
}
|
}
|
||||||
@ -253,7 +297,7 @@ func (c *Client) deleteManifest(ctx context.Context, repo, tag string) error {
|
|||||||
return fmt.Errorf("create delete request: %w", err)
|
return fmt.Errorf("create delete request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
delResp, err := c.httpClient.Do(delReq)
|
delResp, err := c.doWithRetry(delReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete request failed: %w", err)
|
return fmt.Errorf("delete request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
// TODO: Migrate time.Sleep-based tests to testing/synctest (Go 1.25) for
|
||||||
|
// deterministic, instant execution. Priority: TestCircuitBreakerTimerReset,
|
||||||
|
// TestCircuitBreakerRecovery. Also applies to ratelimit_test.go,
|
||||||
|
// api_client_test.go, dispatcher_test.go, cached/project_repository_test.go.
|
||||||
package circuitbreaker
|
package circuitbreaker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -151,7 +151,7 @@ func (s *APIKeyService) Validate(ctx context.Context, rawKey string) (*domain.AP
|
|||||||
// request context since this is a non-critical audit update that should not block
|
// request context since this is a non-critical audit update that should not block
|
||||||
// validation or be cancelled when request completes)
|
// validation or be cancelled when request completes)
|
||||||
go func() {
|
go func() {
|
||||||
_ = s.repo.UpdateLastUsed(context.Background(), apiKey.ID)
|
_ = s.repo.UpdateLastUsed(context.WithoutCancel(ctx), apiKey.ID)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return apiKey, nil
|
return apiKey, nil
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
@ -147,7 +149,7 @@ func (s *ComponentService) buildSiblingServiceURLs(ctx context.Context, projectI
|
|||||||
envKey := toUpperSnake(c.Name) + "_URL"
|
envKey := toUpperSnake(c.Name) + "_URL"
|
||||||
// Build internal K8s service URL: http://projectid-componentname:port
|
// Build internal K8s service URL: http://projectid-componentname:port
|
||||||
serviceName := projectID + "-" + c.Name
|
serviceName := projectID + "-" + c.Name
|
||||||
urls[envKey] = fmt.Sprintf("http://%s:%d", serviceName, c.Port)
|
urls[envKey] = "http://" + net.JoinHostPort(serviceName, strconv.Itoa(c.Port))
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls
|
return urls
|
||||||
|
|||||||
@ -66,7 +66,7 @@ func NewProjectInfraService(
|
|||||||
) *ProjectInfraService {
|
) *ProjectInfraService {
|
||||||
registryURL := cfg.RegistryURL
|
registryURL := cfg.RegistryURL
|
||||||
if registryURL == "" {
|
if registryURL == "" {
|
||||||
registryURL = "registry.threesix.ai" // Default for backward compatibility
|
registryURL = "registry.threesix.ai" // TODO: Remove hardcoded fallback — set REGISTRY_URL in K8s manifest instead
|
||||||
}
|
}
|
||||||
return &ProjectInfraService{
|
return &ProjectInfraService{
|
||||||
db: db,
|
db: db,
|
||||||
|
|||||||
@ -202,7 +202,7 @@ func (d *Dispatcher) processJob(job deliveryJob) {
|
|||||||
// Record the delivery attempt (fire-and-forget: uses dedicated context with
|
// Record the delivery attempt (fire-and-forget: uses dedicated context with
|
||||||
// 10s timeout since recording should not block the job processing loop or
|
// 10s timeout since recording should not block the job processing loop or
|
||||||
// fail if the dispatcher context is cancelled)
|
// fail if the dispatcher context is cancelled)
|
||||||
recordCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
recordCtx, cancel := context.WithTimeout(context.WithoutCancel(d.ctx), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := d.repo.RecordDelivery(recordCtx, delivery); err != nil {
|
if err := d.repo.RecordDelivery(recordCtx, delivery); err != nil {
|
||||||
|
|||||||
@ -85,7 +85,7 @@ func NewHealthHandler(cfg HealthConfig) http.HandlerFunc {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for name, checker := range cfg.Checks {
|
for name, checker := range cfg.Checks {
|
||||||
wg.Add(1)
|
wg.Add(1) // TODO: Migrate to wg.Go() (Go 1.25)
|
||||||
go func(name string, checker HealthChecker) {
|
go func(name string, checker HealthChecker) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user