fix(templates): upgrade Go to 1.25 and fix Woodpecker syntax
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
## Template Version Alignment
- Go: 1.23 → 1.25 across all templates (go.work, go.mod, Dockerfiles, CI)
- Alpine: latest → 3.19 (explicit version pinning)
- Woodpecker: failure:retry → failure:ignore (invalid syntax fix)
## SDLC Tree Fixes (slackpath-5-full-lifecycle)
Fixed merge failures by correcting lifecycle flow:
1. **Branch Creation**: Added missing create-branch step (planned → ready)
- Bug: Merge command requires feature.Branch field to be set
- Fix: POST /projects/{id}/sdlc/features/{slug}/branch
2. **Artifact Status**: Changed approval to pass for execution artifacts
- Bug: Review/audit/QA need status="passed" not "approved"
- Fix: /artifacts/{type}/approve → /artifacts/{type}/pass
- Added: pass-qa step after wait-qa
3. **Phase Transition Order**: Reordered merge phase transition
- Bug: Merge command checks if phase == "merge" first
- Fix: transition-to-merge BEFORE merge-feature (not after)
## GCS Provisioner Fix
- Replaced deprecated option.WithCredentialsFile with env var approach
- Now uses GOOGLE_APPLICATION_CREDENTIALS for ADC (Application Default Credentials)
- Avoids security risk from deprecated credential options
- Fixed test: Added ComponentTypeGCS to ValidComponentTypes test
## Critical Rules Added
- Version alignment: All template versions must stay in sync
- When updating versions, grep entire templates/ tree
## Files Changed
- 27 template files: Go version + Woodpecker syntax
- 1 tree file: SDLC lifecycle flow corrections
- 1 CLAUDE.md: Version alignment rule
- 1 GCS provisioner: Deprecated API fix
- 1 test file: Added missing component type
Root cause: Skeleton templates lagged behind Go 1.25 release and had
invalid Woodpecker syntax. SDLC tree skipped required branch creation
and used wrong artifact approval endpoints.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a419c53592
commit
adcea2fc1f
27
CLAUDE.md
27
CLAUDE.md
@ -77,18 +77,23 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
|
|||||||
- **Error wrapping:** ALWAYS use `%w` (not `%v`) when wrapping errors in `fmt.Errorf`. Using `%v` stringifies the error and breaks `errors.Is`/`errors.As` chains. For non-error types (structs, slices), create a typed error implementing `error` instead of stringifying with `%v`.
|
- **Error wrapping:** ALWAYS use `%w` (not `%v`) when wrapping errors in `fmt.Errorf`. Using `%v` stringifies the error and breaks `errors.Is`/`errors.As` chains. For non-error types (structs, slices), create a typed error implementing `error` instead of stringifying with `%v`.
|
||||||
- **Context propagation:** NEVER use `context.Background()` in handlers, services, or adapters that receive a context parameter. Always derive from parent context. Use `context.WithoutCancel(ctx)` for fire-and-forget goroutines that need tracing but independent cancellation.
|
- **Context propagation:** NEVER use `context.Background()` in handlers, services, or adapters that receive a context parameter. Always derive from parent context. Use `context.WithoutCancel(ctx)` for fire-and-forget goroutines that need tracing but independent cancellation.
|
||||||
- **Cookbooks:** Load `.claude/skills/cookbook-scripts/SKILL.md` before writing/modifying any cookbook script or tree.
|
- **Cookbooks:** Load `.claude/skills/cookbook-scripts/SKILL.md` before writing/modifying any cookbook script or tree.
|
||||||
|
- **Version alignment:** Skeleton templates MUST use consistent versions across all files: Go 1.25 (go.work, go.mod, Dockerfiles, CI images), Node 20, Alpine 3.19. When updating a version, grep the entire templates/ tree and update ALL occurrences to prevent drift.
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required env vars (add to ~/.zshrc)
|
# Environment (should already be in ~/.zshrc)
|
||||||
export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
|
export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
|
||||||
export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
|
export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
|
||||||
export RDEV_API_KEY="<from rdev-credentials secret>"
|
export RDEV_API_KEY="<your-api-key>" # Already set in ~/.zshrc
|
||||||
|
|
||||||
# Infrastructure credentials stored in .secrets (gitignored)
|
# Verify environment is loaded
|
||||||
# See: .claude/guides/ops/credentials.md for setup
|
echo $RDEV_API_KEY # Should print a base64 string
|
||||||
# Keys: GITEA_TOKEN, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, WOODPECKER_*
|
# If empty: source ~/.zshrc
|
||||||
|
|
||||||
|
# For scripts: use cookbooks/scripts/common.sh library
|
||||||
|
# Provides: api_call(), wait_for_build(), wait_for_pipeline(), wait_for_site()
|
||||||
|
# Example: source "$(dirname "$0")/common.sh" && api_call GET "/health"
|
||||||
|
|
||||||
# Run locally
|
# Run locally
|
||||||
go run ./cmd/rdev-api
|
go run ./cmd/rdev-api
|
||||||
@ -120,11 +125,13 @@ rdev-logs # Last 100 lines
|
|||||||
rdev-logs-f # Follow/stream
|
rdev-logs-f # Follow/stream
|
||||||
rdev-pods # List pods
|
rdev-pods # List pods
|
||||||
|
|
||||||
# API calls (NOTE: $RDEV_API_KEY doesn't expand in curl -H, use the test script instead)
|
# API calls - use cookbook test scripts (they handle auth via common.sh)
|
||||||
# ./cookbooks/scripts/landing-test.sh run|status|teardown <name>
|
./cookbooks/scripts/landing-test.sh run|status|teardown <name>
|
||||||
curl -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/health
|
./cookbooks/scripts/tree-runner.sh run <tree-name> --project-name <name>
|
||||||
curl -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/projects
|
|
||||||
curl -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/work/stats
|
# Or direct API calls (requires env vars above)
|
||||||
|
curl -s -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/health | jq
|
||||||
|
curl -s -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/projects | jq
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|||||||
@ -77,6 +77,11 @@ type InfraConfig struct {
|
|||||||
RedisHost string // e.g., "redis.databases.svc.cluster.local"
|
RedisHost string // e.g., "redis.databases.svc.cluster.local"
|
||||||
RedisPort int // e.g., 6379
|
RedisPort int // e.g., 6379
|
||||||
RedisPassword string // admin password for ACL management
|
RedisPassword string // admin password for ACL management
|
||||||
|
|
||||||
|
// GCS provisioner (for project storage)
|
||||||
|
GCSProjectID string // e.g., "threesix-prod"
|
||||||
|
GCSCredentialsPath string // Path to service account JSON (empty = ADC)
|
||||||
|
GCSLocation string // Bucket location (default: "US")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() Config {
|
func loadConfig() Config {
|
||||||
@ -169,6 +174,11 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
|||||||
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.databases.svc.cluster.local"
|
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.databases.svc.cluster.local"
|
||||||
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
|
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
|
||||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||||
|
|
||||||
|
// GCS provisioner (env-only)
|
||||||
|
GCSProjectID: os.Getenv("GCS_PROJECT_ID"),
|
||||||
|
GCSCredentialsPath: os.Getenv("GCS_CREDENTIALS_PATH"),
|
||||||
|
GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log which credentials were loaded from store vs env
|
// Log which credentials were loaded from store vs env
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/orchard9/rdev/internal/adapter/codeagent/claudecode"
|
"github.com/orchard9/rdev/internal/adapter/codeagent/claudecode"
|
||||||
"github.com/orchard9/rdev/internal/adapter/codeagent/opencode"
|
"github.com/orchard9/rdev/internal/adapter/codeagent/opencode"
|
||||||
"github.com/orchard9/rdev/internal/adapter/deployer"
|
"github.com/orchard9/rdev/internal/adapter/deployer"
|
||||||
|
gcsadapter "github.com/orchard9/rdev/internal/adapter/gcs"
|
||||||
"github.com/orchard9/rdev/internal/adapter/gitea"
|
"github.com/orchard9/rdev/internal/adapter/gitea"
|
||||||
"github.com/orchard9/rdev/internal/adapter/kubernetes"
|
"github.com/orchard9/rdev/internal/adapter/kubernetes"
|
||||||
"github.com/orchard9/rdev/internal/adapter/memory"
|
"github.com/orchard9/rdev/internal/adapter/memory"
|
||||||
@ -200,6 +201,24 @@ func main() {
|
|||||||
logger.Info("redis provisioner initialized", "host", infraCfg.RedisHost)
|
logger.Info("redis provisioner initialized", "host", infraCfg.RedisHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
defer closeProvisioner(cacheProvisioner, "redis", logger)
|
||||||
|
|
||||||
|
// Initialize storage provisioner (optional - for project storage via GCS)
|
||||||
|
var storageProvisioner port.StorageProvisioner
|
||||||
|
if infraCfg.GCSProjectID != "" {
|
||||||
|
var err error
|
||||||
|
storageProvisioner, err = gcsadapter.NewProvisioner(gcsadapter.Config{
|
||||||
|
GoogleProjectID: infraCfg.GCSProjectID,
|
||||||
|
CredentialsPath: infraCfg.GCSCredentialsPath,
|
||||||
|
Location: infraCfg.GCSLocation,
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("failed to initialize gcs provisioner", "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("gcs provisioner initialized", "project_id", infraCfg.GCSProjectID, "location", infraCfg.GCSLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer closeProvisioner(storageProvisioner, "gcs", logger)
|
||||||
|
|
||||||
// Initialize registry client (for monitoring and image cleanup on project teardown)
|
// Initialize registry client (for monitoring and image cleanup on project teardown)
|
||||||
var registryClient *zot.Client
|
var registryClient *zot.Client
|
||||||
@ -360,13 +379,16 @@ func main() {
|
|||||||
ClusterIP: infraCfg.ClusterIP,
|
ClusterIP: infraCfg.ClusterIP,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Wire optional database, cache, and registry provisioners
|
// Wire optional database, cache, storage, and registry provisioners
|
||||||
if dbProvisioner != nil {
|
if dbProvisioner != nil {
|
||||||
projectInfraService = projectInfraService.WithDatabaseProvisioner(dbProvisioner)
|
projectInfraService = projectInfraService.WithDatabaseProvisioner(dbProvisioner)
|
||||||
}
|
}
|
||||||
if cacheProvisioner != nil {
|
if cacheProvisioner != nil {
|
||||||
projectInfraService = projectInfraService.WithCacheProvisioner(cacheProvisioner)
|
projectInfraService = projectInfraService.WithCacheProvisioner(cacheProvisioner)
|
||||||
}
|
}
|
||||||
|
if storageProvisioner != nil {
|
||||||
|
projectInfraService = projectInfraService.WithStorageProvisioner(storageProvisioner)
|
||||||
|
}
|
||||||
if registryClient != nil {
|
if registryClient != nil {
|
||||||
projectInfraService = projectInfraService.WithRegistryProvider(registryClient)
|
projectInfraService = projectInfraService.WithRegistryProvider(registryClient)
|
||||||
}
|
}
|
||||||
@ -409,6 +431,7 @@ func main() {
|
|||||||
).
|
).
|
||||||
WithDatabaseProvisioner(dbProvisioner).
|
WithDatabaseProvisioner(dbProvisioner).
|
||||||
WithCacheProvisioner(cacheProvisioner).
|
WithCacheProvisioner(cacheProvisioner).
|
||||||
|
WithStorageProvisioner(storageProvisioner).
|
||||||
WithCredentialStore(credentialStore)
|
WithCredentialStore(credentialStore)
|
||||||
componentsHandler = handlers.NewComponentsHandler(componentService).
|
componentsHandler = handlers.NewComponentsHandler(componentService).
|
||||||
SetOperationService(operationService)
|
SetOperationService(operationService)
|
||||||
|
|||||||
@ -60,6 +60,13 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
|
|||||||
spec.WithTag("Builds", "Build orchestration and history")
|
spec.WithTag("Builds", "Build orchestration and history")
|
||||||
spec.WithTag("SDLC", "Software Development Lifecycle orchestration")
|
spec.WithTag("SDLC", "Software Development Lifecycle orchestration")
|
||||||
spec.WithTag("System", "Health and readiness endpoints")
|
spec.WithTag("System", "Health and readiness endpoints")
|
||||||
|
spec.WithTag("Components", "Composable monorepo component management")
|
||||||
|
spec.WithTag("Credentials", "Infrastructure credential management")
|
||||||
|
spec.WithTag("Diagnostics", "Project health and diagnostics")
|
||||||
|
spec.WithTag("Verify", "Visual verification with Playwright")
|
||||||
|
spec.WithTag("Webhooks", "External webhook receivers")
|
||||||
|
spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management")
|
||||||
|
spec.WithTag("Sagas", "Distributed workflow orchestration with compensation")
|
||||||
|
|
||||||
// Register all path operations
|
// Register all path operations
|
||||||
registerSystemPaths(spec)
|
registerSystemPaths(spec)
|
||||||
@ -73,6 +80,13 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
|
|||||||
registerWorkerPaths(spec)
|
registerWorkerPaths(spec)
|
||||||
registerBuildPaths(spec)
|
registerBuildPaths(spec)
|
||||||
registerSDLCPaths(spec)
|
registerSDLCPaths(spec)
|
||||||
|
registerComponentPaths(spec)
|
||||||
|
registerCredentialPaths(spec)
|
||||||
|
registerDiagnosticsPaths(spec)
|
||||||
|
registerVerifyPaths(spec)
|
||||||
|
registerWebhookPaths(spec)
|
||||||
|
registerInfrastructurePaths(spec)
|
||||||
|
registerSagaPaths(spec)
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|||||||
@ -656,3 +656,850 @@ Combines project creation (git repo, DNS, CI activation) with build submission i
|
|||||||
}`,
|
}`,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerComponentPaths(spec *api.OpenAPISpec) {
|
||||||
|
spec.AddPath("/projects/{id}/components", "get", withAuthAndParams(
|
||||||
|
"List components",
|
||||||
|
`Returns all components in a project's composable monorepo.
|
||||||
|
|
||||||
|
Each project can contain multiple components (services, workers, apps, CLIs) organized in a monorepo structure.`,
|
||||||
|
"Components",
|
||||||
|
"projects:read",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/components", "post", withAuthBodyAndParams(
|
||||||
|
"Add component",
|
||||||
|
`Adds a new component to a project's monorepo.
|
||||||
|
|
||||||
|
Generates component skeleton from template, updates configuration, and commits changes.`,
|
||||||
|
"Components",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
`{
|
||||||
|
"type": "service",
|
||||||
|
"name": "api-gateway",
|
||||||
|
"template": "go-rest-api",
|
||||||
|
"port": 8080
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"type": "service",
|
||||||
|
"name": "api-gateway",
|
||||||
|
"path": "services/api-gateway",
|
||||||
|
"port": 8080,
|
||||||
|
"template": "go-rest-api",
|
||||||
|
"dependencies": [],
|
||||||
|
"operation_id": "op-abc123"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/components/batch", "post", withAuthBodyAndParams(
|
||||||
|
"Add multiple components",
|
||||||
|
`Adds multiple components to a project's monorepo atomically.
|
||||||
|
|
||||||
|
All components are created in a single transaction. If any component fails, the entire operation is rolled back.`,
|
||||||
|
"Components",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
`{
|
||||||
|
"components": [
|
||||||
|
{"type": "service", "name": "auth", "port": 8081},
|
||||||
|
{"type": "service", "name": "users", "port": 8082},
|
||||||
|
{"type": "worker", "name": "email-processor"}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "service",
|
||||||
|
"name": "auth",
|
||||||
|
"path": "services/auth",
|
||||||
|
"port": 8081,
|
||||||
|
"template": "go-rest-api",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "service",
|
||||||
|
"name": "users",
|
||||||
|
"path": "services/users",
|
||||||
|
"port": 8082,
|
||||||
|
"template": "go-rest-api",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "worker",
|
||||||
|
"name": "email-processor",
|
||||||
|
"path": "workers/email-processor",
|
||||||
|
"port": 0,
|
||||||
|
"template": "go-worker",
|
||||||
|
"dependencies": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"operation_id": "op-abc123"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
// Use wildcard for dynamic component path
|
||||||
|
spec.AddPath("/projects/{id}/components/{path}", "delete", map[string]any{
|
||||||
|
"summary": "Remove component",
|
||||||
|
"description": "Removes a component from the project's monorepo.\n\n**Required scope**: `projects:execute`",
|
||||||
|
"tags": []string{"Components"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project ID",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Component path (e.g., services/api-gateway, workers/email-processor)",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"204": map[string]any{"description": "No Content - Component removed"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
"404": map[string]any{"description": "Not Found - Component not found"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCredentialPaths(spec *api.OpenAPISpec) {
|
||||||
|
spec.AddPath("/credentials", "get", map[string]any{
|
||||||
|
"summary": "List credentials",
|
||||||
|
"description": `Returns all infrastructure credentials with values masked.
|
||||||
|
|
||||||
|
Optionally filter by category using ?category=<name> query parameter.
|
||||||
|
|
||||||
|
**Required scope**: ` + "`admin`",
|
||||||
|
"tags": []string{"Credentials"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "category",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by category (e.g., git, ci, dns, registry)",
|
||||||
|
"required": false,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{
|
||||||
|
"description": "Success",
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"example": `[
|
||||||
|
{
|
||||||
|
"key": "GITEA_TOKEN",
|
||||||
|
"value": "***",
|
||||||
|
"description": "Gitea API token for repo operations",
|
||||||
|
"category": "git",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-15T10:30:00Z",
|
||||||
|
"updated_by": "admin"
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Admin scope required"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
spec.AddPath("/credentials/{key}", "get", withAuthAndParams(
|
||||||
|
"Get credential",
|
||||||
|
`Returns a single credential by key with full unmasked value.
|
||||||
|
|
||||||
|
**Security**: Only admin scope can retrieve unmasked credentials.`,
|
||||||
|
"Credentials",
|
||||||
|
"admin",
|
||||||
|
[]param{{Name: "key", In: "path", Description: "Credential key (e.g., GITEA_TOKEN, WOODPECKER_SECRET)", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/credentials", "post", withAuthAndBody(
|
||||||
|
"Set credential",
|
||||||
|
`Creates or updates a single credential.
|
||||||
|
|
||||||
|
Credentials are encrypted at rest in PostgreSQL.`,
|
||||||
|
"Credentials",
|
||||||
|
"admin",
|
||||||
|
`{
|
||||||
|
"key": "GITEA_TOKEN",
|
||||||
|
"value": "abc123xyz",
|
||||||
|
"description": "Gitea API token for repo operations",
|
||||||
|
"category": "git"
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"status": "stored",
|
||||||
|
"key": "GITEA_TOKEN"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/credentials/batch", "post", withAuthAndBody(
|
||||||
|
"Set multiple credentials",
|
||||||
|
`Creates or updates multiple credentials atomically.
|
||||||
|
|
||||||
|
Useful for bulk credential loading from configuration files.`,
|
||||||
|
"Credentials",
|
||||||
|
"admin",
|
||||||
|
`{
|
||||||
|
"credentials": [
|
||||||
|
{"key": "GITEA_TOKEN", "value": "abc123", "category": "git"},
|
||||||
|
{"key": "WOODPECKER_SECRET", "value": "xyz789", "category": "ci"}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"status": "stored",
|
||||||
|
"count": 2,
|
||||||
|
"keys": ["GITEA_TOKEN", "WOODPECKER_SECRET"]
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/credentials/{key}", "delete", map[string]any{
|
||||||
|
"summary": "Delete credential",
|
||||||
|
"description": "Removes a credential permanently.\n\n**Required scope**: `admin`",
|
||||||
|
"tags": []string{"Credentials"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Credential key to delete",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{
|
||||||
|
"description": "Success",
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"example": `{"status": "deleted", "key": "GITEA_TOKEN"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Admin scope required"},
|
||||||
|
"404": map[string]any{"description": "Not Found - Credential not found"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerDiagnosticsPaths(spec *api.OpenAPISpec) {
|
||||||
|
spec.AddPath("/projects/{projectId}/diagnostics", "get", withAuthAndParams(
|
||||||
|
"Get project diagnostics",
|
||||||
|
`Returns comprehensive health and diagnostic information for a project.
|
||||||
|
|
||||||
|
Includes pod status, resource usage, recent errors, external system connectivity, and deployment health.`,
|
||||||
|
"Diagnostics",
|
||||||
|
"projects:read",
|
||||||
|
[]param{{Name: "projectId", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerVerifyPaths(spec *api.OpenAPISpec) {
|
||||||
|
spec.AddPath("/verify", "post", withAuthAndBody(
|
||||||
|
"Submit verification task",
|
||||||
|
`Submits a visual verification task using Playwright.
|
||||||
|
|
||||||
|
Captures screenshots at multiple viewports and optionally records video. Results include screenshot URLs and AI analysis of visual quality.`,
|
||||||
|
"Verify",
|
||||||
|
"verify:write",
|
||||||
|
`{
|
||||||
|
"project_id": "my-landing-page",
|
||||||
|
"url": "https://my-landing-page.threesix.ai",
|
||||||
|
"viewports": ["desktop", "tablet", "mobile"],
|
||||||
|
"wait_for": "networkidle",
|
||||||
|
"wait_timeout": 30000,
|
||||||
|
"full_page": true,
|
||||||
|
"video": true,
|
||||||
|
"callback_url": "https://myapp.com/webhooks/verify"
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"task_id": "verify-abc123",
|
||||||
|
"status_url": "/verify/verify-abc123",
|
||||||
|
"stream_url": "/verify/verify-abc123/stream"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/verify/{taskId}", "get", withAuthAndParams(
|
||||||
|
"Get verification status",
|
||||||
|
`Returns the status and results of a verification task.
|
||||||
|
|
||||||
|
Includes screenshot URLs, video URL (if recorded), and duration.`,
|
||||||
|
"Verify",
|
||||||
|
"verify:read",
|
||||||
|
[]param{{Name: "taskId", In: "path", Description: "Verification task ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/verify/{taskId}/stream", "get", map[string]any{
|
||||||
|
"summary": "Stream verification events",
|
||||||
|
"description": `Streams real-time verification task events via Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
Events include task started, screenshot captured, video recorded, task completed/failed.
|
||||||
|
|
||||||
|
**Required scope**: ` + "`verify:read`",
|
||||||
|
"tags": []string{"Verify"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "taskId",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Verification task ID",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{
|
||||||
|
"description": "Success - SSE stream",
|
||||||
|
"content": map[string]any{
|
||||||
|
"text/event-stream": map[string]any{
|
||||||
|
"example": `event: connected
|
||||||
|
data: {"task_id":"verify-abc123"}
|
||||||
|
|
||||||
|
event: started
|
||||||
|
data: {"viewport":"desktop","url":"https://my-landing-page.threesix.ai"}
|
||||||
|
|
||||||
|
event: screenshot_captured
|
||||||
|
data: {"viewport":"desktop","url":"https://storage/verify-abc123-desktop.png"}
|
||||||
|
|
||||||
|
event: completed
|
||||||
|
data: {"task_id":"verify-abc123","duration_ms":5432}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
"404": map[string]any{"description": "Not Found - Task not found"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
spec.AddPath("/verify/{taskId}", "delete", withAuthAndParams(
|
||||||
|
"Cancel verification task",
|
||||||
|
`Cancels a pending or running verification task.
|
||||||
|
|
||||||
|
Stops Playwright browser and marks task as cancelled.`,
|
||||||
|
"Verify",
|
||||||
|
"verify:write",
|
||||||
|
[]param{{Name: "taskId", In: "path", Description: "Verification task ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/verify", "get", map[string]any{
|
||||||
|
"summary": "List project verifications",
|
||||||
|
"description": `Returns verification tasks for a project with pagination.
|
||||||
|
|
||||||
|
**Required scope**: ` + "`verify:read`",
|
||||||
|
"tags": []string{"Verify"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project ID",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Maximum number of results (default: 50)",
|
||||||
|
"required": false,
|
||||||
|
"schema": map[string]any{"type": "integer", "default": 50},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "offset",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Pagination offset (default: 0)",
|
||||||
|
"required": false,
|
||||||
|
"schema": map[string]any{"type": "integer", "default": 0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{"description": "Success"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerInfrastructurePaths(spec *api.OpenAPISpec) {
|
||||||
|
// Git repository endpoints
|
||||||
|
spec.AddPath("/projects/{id}/repo", "post", withAuthBodyAndParams(
|
||||||
|
"Create repository",
|
||||||
|
`Creates a git repository for a project in Gitea.
|
||||||
|
|
||||||
|
Repository is created under the configured git owner (e.g., threesix organization).`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID (will be repository name)", Required: true}},
|
||||||
|
`{
|
||||||
|
"description": "Landing page for product launch",
|
||||||
|
"private": false
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"id": 123,
|
||||||
|
"owner": "threesix",
|
||||||
|
"name": "my-landing-page",
|
||||||
|
"full_name": "threesix/my-landing-page",
|
||||||
|
"description": "Landing page for product launch",
|
||||||
|
"private": false,
|
||||||
|
"clone_ssh": "git@git.threesix.ai:threesix/my-landing-page.git",
|
||||||
|
"clone_http": "https://git.threesix.ai/threesix/my-landing-page.git",
|
||||||
|
"html_url": "https://git.threesix.ai/threesix/my-landing-page"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/repo", "get", withAuthAndParams(
|
||||||
|
"Get repository",
|
||||||
|
`Returns repository information for a project.
|
||||||
|
|
||||||
|
Includes clone URLs, visibility, and metadata.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:read",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/repo", "delete", withAuthAndParams(
|
||||||
|
"Delete repository",
|
||||||
|
`Deletes the git repository for a project.
|
||||||
|
|
||||||
|
**Warning**: This permanently deletes all git history and branches.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
// Deployment endpoints
|
||||||
|
spec.AddPath("/projects/{id}/deploy", "post", withAuthAndParams(
|
||||||
|
"Deploy project",
|
||||||
|
`Deploys a project to Kubernetes.
|
||||||
|
|
||||||
|
Creates deployment, service, and ingress resources.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/deploy/status", "get", withAuthAndParams(
|
||||||
|
"Get deployment status",
|
||||||
|
`Returns the current deployment status for a project.
|
||||||
|
|
||||||
|
Includes pod status, replica counts, and health information.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:read",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/deploy", "delete", withAuthAndParams(
|
||||||
|
"Undeploy project",
|
||||||
|
`Removes deployment, service, and ingress resources for a project.
|
||||||
|
|
||||||
|
Pods are terminated and all Kubernetes resources are deleted.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/deploy/restart", "post", withAuthAndParams(
|
||||||
|
"Restart deployment",
|
||||||
|
`Restarts a project's deployment by recreating all pods.
|
||||||
|
|
||||||
|
Useful for applying configuration changes or recovering from errors.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/deploy/scale", "post", withAuthBodyAndParams(
|
||||||
|
"Scale deployment",
|
||||||
|
`Scales a project's deployment to the specified replica count.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
`{"replicas": 3}`,
|
||||||
|
`{"status": "scaled", "replicas": 3}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/deploy/logs", "get", map[string]any{
|
||||||
|
"summary": "Get deployment logs",
|
||||||
|
"description": `Returns recent logs from a project's deployment pods.
|
||||||
|
|
||||||
|
**Required scope**: ` + "`projects:read`",
|
||||||
|
"tags": []string{"Infrastructure"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project ID",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tail",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Number of recent log lines to retrieve (default: 100)",
|
||||||
|
"required": false,
|
||||||
|
"schema": map[string]any{"type": "integer", "default": 100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{"description": "Success"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
"404": map[string]any{"description": "Not Found - Project not found"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Domain endpoints
|
||||||
|
spec.AddPath("/projects/{id}/domain", "post", withAuthBodyAndParams(
|
||||||
|
"Add domain",
|
||||||
|
`Adds a custom domain to a project.
|
||||||
|
|
||||||
|
Creates DNS A record if domain is a subdomain of the configured base domain (e.g., threesix.ai).`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
`{"domain": "custom.example.com"}`,
|
||||||
|
`{
|
||||||
|
"project": "my-landing-page",
|
||||||
|
"domain": "custom.example.com",
|
||||||
|
"status": "configured",
|
||||||
|
"note": "External domain configured. Point your DNS to 208.122.204.172"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/domain", "delete", map[string]any{
|
||||||
|
"summary": "Remove domain",
|
||||||
|
"description": `Removes a custom domain from a project.
|
||||||
|
|
||||||
|
Deletes DNS A record if domain was a managed subdomain.
|
||||||
|
|
||||||
|
**Required scope**: ` + "`projects:execute`",
|
||||||
|
"tags": []string{"Infrastructure"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project ID",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "domain",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Domain to remove (e.g., custom.example.com)",
|
||||||
|
"required": true,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{"description": "Success"},
|
||||||
|
"400": map[string]any{"description": "Bad Request - Missing domain parameter"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/domains", "get", withAuthAndParams(
|
||||||
|
"List domains",
|
||||||
|
`Returns all domains (primary and aliases) for a project.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:read",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/domains", "post", withAuthBodyAndParams(
|
||||||
|
"Add domain alias",
|
||||||
|
`Adds an additional domain alias to a project.
|
||||||
|
|
||||||
|
Projects can have multiple domains pointing to the same deployment.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
`{"domain": "alias.example.com", "proxied": false}`,
|
||||||
|
`{"status": "added", "domain": "alias.example.com"}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/domains/{domain}", "delete", withAuthAndParams(
|
||||||
|
"Remove domain alias",
|
||||||
|
`Removes a domain alias from a project.
|
||||||
|
|
||||||
|
Primary domain cannot be removed via this endpoint.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{
|
||||||
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
||||||
|
{Name: "domain", In: "path", Description: "Domain to remove", Required: true},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
// CI pipeline endpoints
|
||||||
|
spec.AddPath("/projects/{id}/pipelines", "get", withAuthAndParams(
|
||||||
|
"List CI pipelines",
|
||||||
|
`Returns recent CI pipeline runs for a project.
|
||||||
|
|
||||||
|
Includes build status, branch, commit info, and duration.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:read",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/pipelines/{number}", "get", withAuthAndParams(
|
||||||
|
"Get pipeline details",
|
||||||
|
`Returns detailed information for a specific pipeline run.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:read",
|
||||||
|
[]param{
|
||||||
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
||||||
|
{Name: "number", In: "path", Description: "Pipeline number", Required: true},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/pipelines/{number}/steps", "get", withAuthAndParams(
|
||||||
|
"Get pipeline steps",
|
||||||
|
`Returns the steps (stages) of a pipeline run with status and logs.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:read",
|
||||||
|
[]param{
|
||||||
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
||||||
|
{Name: "number", In: "path", Description: "Pipeline number", Required: true},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/projects/{id}/pipelines/{number}/retry", "post", withAuthAndParams(
|
||||||
|
"Retry pipeline",
|
||||||
|
`Retries a failed pipeline from the failed step.
|
||||||
|
|
||||||
|
Part of the enterprise resilience architecture for handling transient failures.`,
|
||||||
|
"Infrastructure",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{
|
||||||
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
||||||
|
{Name: "number", In: "path", Description: "Pipeline number to retry", Required: true},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerWebhookPaths(spec *api.OpenAPISpec) {
|
||||||
|
spec.AddPath("/webhooks/woodpecker", "post", map[string]any{
|
||||||
|
"summary": "Woodpecker CI webhook",
|
||||||
|
"description": `Receives build event webhooks from Woodpecker CI.
|
||||||
|
|
||||||
|
**Authentication**: Uses HMAC-SHA256 signature verification (X-Woodpecker-Signature header), not API key auth.
|
||||||
|
|
||||||
|
Processes successful builds on main/master branches to trigger deployments and DNS updates.`,
|
||||||
|
"tags": []string{"Webhooks"},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "X-Woodpecker-Signature",
|
||||||
|
"in": "header",
|
||||||
|
"description": "HMAC-SHA256 signature of request body",
|
||||||
|
"required": false,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"requestBody": map[string]any{
|
||||||
|
"required": true,
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"example": `{
|
||||||
|
"event": "push",
|
||||||
|
"repo": {
|
||||||
|
"owner": "threesix",
|
||||||
|
"name": "my-landing-page",
|
||||||
|
"full_name": "threesix/my-landing-page"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"number": 42,
|
||||||
|
"status": "success",
|
||||||
|
"branch": "main",
|
||||||
|
"commit": "abc123def456",
|
||||||
|
"message": "Add feature X",
|
||||||
|
"author": "jordan"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{
|
||||||
|
"description": "Success - Event processed",
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"example": `{
|
||||||
|
"status": "success",
|
||||||
|
"project": "my-landing-page",
|
||||||
|
"image": "registry.threesix.ai/my-landing-page:abc123de",
|
||||||
|
"commit": "abc123def456",
|
||||||
|
"note": "component deployments managed by CI pipeline"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": map[string]any{"description": "Bad Request - Invalid payload"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Invalid signature"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSagaPaths(spec *api.OpenAPISpec) {
|
||||||
|
spec.AddPath("/sagas", "post", withAuthAndBody(
|
||||||
|
"Create and start saga",
|
||||||
|
`Creates a new saga workflow and starts execution.
|
||||||
|
|
||||||
|
Sagas are distributed workflows with automatic compensation on failure. Useful for multi-step operations like project creation with rollback support.`,
|
||||||
|
"Sagas",
|
||||||
|
"projects:execute",
|
||||||
|
`{
|
||||||
|
"name": "create-full-stack-project",
|
||||||
|
"max_retries": 3,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "create-repo",
|
||||||
|
"action": "api",
|
||||||
|
"compensate": "delete-repo",
|
||||||
|
"config": {"method": "POST", "endpoint": "/projects/{id}/repo"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create-db",
|
||||||
|
"action": "api",
|
||||||
|
"depends_on": ["create-repo"],
|
||||||
|
"compensate": "delete-db",
|
||||||
|
"config": {"method": "POST", "endpoint": "/projects/{id}/database"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"id": "saga-abc123",
|
||||||
|
"name": "create-full-stack-project",
|
||||||
|
"status": "running",
|
||||||
|
"current_step": "create-repo",
|
||||||
|
"retry_count": 0,
|
||||||
|
"max_retries": 3,
|
||||||
|
"created_at": "2026-02-08T10:00:00Z",
|
||||||
|
"updated_at": "2026-02-08T10:00:00Z"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/sagas", "get", map[string]any{
|
||||||
|
"summary": "List sagas",
|
||||||
|
"description": `Returns sagas with optional filtering.
|
||||||
|
|
||||||
|
**Query parameters**: ?name=<name>&status=<status>
|
||||||
|
|
||||||
|
**Required scope**: ` + "`projects:read`",
|
||||||
|
"tags": []string{"Sagas"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by saga name",
|
||||||
|
"required": false,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by status (pending, running, completed, failed, compensating, compensated)",
|
||||||
|
"required": false,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{"description": "Success"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
spec.AddPath("/sagas/{id}", "get", withAuthAndParams(
|
||||||
|
"Get saga",
|
||||||
|
`Returns detailed saga execution state including all steps, outputs, and errors.`,
|
||||||
|
"Sagas",
|
||||||
|
"projects:read",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Saga ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/sagas/{id}", "delete", withAuthAndParams(
|
||||||
|
"Delete saga",
|
||||||
|
`Deletes a saga and all associated state.
|
||||||
|
|
||||||
|
Running sagas should be cancelled before deletion.`,
|
||||||
|
"Sagas",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Saga ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/sagas/{id}/retry", "post", withAuthAndParams(
|
||||||
|
"Retry failed saga",
|
||||||
|
`Resumes a failed saga from the last failed step.
|
||||||
|
|
||||||
|
Increments retry count and attempts to continue execution.`,
|
||||||
|
"Sagas",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Saga ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/sagas/{id}/rollback", "post", withAuthAndParams(
|
||||||
|
"Rollback saga",
|
||||||
|
`Triggers compensation for a failed saga.
|
||||||
|
|
||||||
|
Runs compensation actions in reverse order to undo completed steps.`,
|
||||||
|
"Sagas",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Saga ID", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/sagas/{id}/steps/{step}/retry", "post", withAuthAndParams(
|
||||||
|
"Retry specific step",
|
||||||
|
`Retries a specific failed step within a saga.
|
||||||
|
|
||||||
|
Does not increment saga-level retry count.`,
|
||||||
|
"Sagas",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{
|
||||||
|
{Name: "id", In: "path", Description: "Saga ID", Required: true},
|
||||||
|
{Name: "step", In: "path", Description: "Step name", Required: true},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/sagas/{id}/steps/{step}/skip", "post", withAuthAndParams(
|
||||||
|
"Skip step",
|
||||||
|
`Skips a failed step and continues saga execution.
|
||||||
|
|
||||||
|
Marks step as skipped and allows dependent steps to proceed.`,
|
||||||
|
"Sagas",
|
||||||
|
"projects:execute",
|
||||||
|
[]param{
|
||||||
|
{Name: "id", In: "path", Description: "Saga ID", Required: true},
|
||||||
|
{Name: "step", In: "path", Description: "Step name to skip", Required: true},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
@ -233,11 +233,20 @@ steps:
|
|||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 4: PLANNED → READY
|
# PHASE 4: PLANNED → READY
|
||||||
# No new artifacts needed, just transition
|
# Create git branch, then transition
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
create-branch:
|
||||||
|
description: "Create feature branch"
|
||||||
|
depends_on: [transition-to-planned]
|
||||||
|
action: api
|
||||||
|
method: POST
|
||||||
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/branch"
|
||||||
|
outputs:
|
||||||
|
- branch_name: .data.name
|
||||||
|
|
||||||
transition-to-ready:
|
transition-to-ready:
|
||||||
description: "Transition from planned to ready"
|
description: "Transition from planned to ready"
|
||||||
depends_on: [transition-to-planned]
|
depends_on: [create-branch]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
||||||
@ -314,18 +323,16 @@ steps:
|
|||||||
max_attempts: 720
|
max_attempts: 720
|
||||||
poll_interval: 5
|
poll_interval: 5
|
||||||
|
|
||||||
approve-review:
|
pass-review:
|
||||||
description: "API approves the review"
|
description: "Mark review as passed"
|
||||||
depends_on: [wait-review]
|
depends_on: [wait-review]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/artifacts/review/approve"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/artifacts/review/pass"
|
||||||
body:
|
|
||||||
comment: "Review approved by automation"
|
|
||||||
|
|
||||||
transition-to-review:
|
transition-to-review:
|
||||||
description: "Transition to review phase"
|
description: "Transition to review phase"
|
||||||
depends_on: [approve-review]
|
depends_on: [pass-review]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
||||||
@ -359,18 +366,16 @@ steps:
|
|||||||
max_attempts: 720
|
max_attempts: 720
|
||||||
poll_interval: 5
|
poll_interval: 5
|
||||||
|
|
||||||
approve-audit:
|
pass-audit:
|
||||||
description: "API approves the audit"
|
description: "Mark audit as passed"
|
||||||
depends_on: [wait-audit]
|
depends_on: [wait-audit]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/artifacts/audit/approve"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/artifacts/audit/pass"
|
||||||
body:
|
|
||||||
comment: "Audit approved by automation"
|
|
||||||
|
|
||||||
transition-to-audit:
|
transition-to-audit:
|
||||||
description: "Transition to audit phase"
|
description: "Transition to audit phase"
|
||||||
depends_on: [approve-audit]
|
depends_on: [pass-audit]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
||||||
@ -404,9 +409,16 @@ steps:
|
|||||||
max_attempts: 720
|
max_attempts: 720
|
||||||
poll_interval: 5
|
poll_interval: 5
|
||||||
|
|
||||||
|
pass-qa:
|
||||||
|
description: "Mark QA as passed"
|
||||||
|
depends_on: [wait-qa]
|
||||||
|
action: api
|
||||||
|
method: POST
|
||||||
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/artifacts/qa_results/pass"
|
||||||
|
|
||||||
transition-to-qa:
|
transition-to-qa:
|
||||||
description: "Transition to QA phase"
|
description: "Transition to QA phase"
|
||||||
depends_on: [wait-qa]
|
depends_on: [pass-qa]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
||||||
@ -417,22 +429,11 @@ steps:
|
|||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 9: QA → MERGE
|
# PHASE 9: QA → MERGE
|
||||||
# Merge feature branch to main
|
# Transition to merge phase, then merge feature branch to main
|
||||||
# ============================================================
|
# ============================================================
|
||||||
merge-feature:
|
|
||||||
description: "Merge feature branch to main"
|
|
||||||
depends_on: [transition-to-qa]
|
|
||||||
action: api
|
|
||||||
method: POST
|
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/merge"
|
|
||||||
body:
|
|
||||||
strategy: "squash"
|
|
||||||
outputs:
|
|
||||||
- merge_commit: .data.commit_sha
|
|
||||||
|
|
||||||
transition-to-merge:
|
transition-to-merge:
|
||||||
description: "Transition to merge phase"
|
description: "Transition to merge phase"
|
||||||
depends_on: [merge-feature]
|
depends_on: [transition-to-qa]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/transition"
|
||||||
@ -441,13 +442,24 @@ steps:
|
|||||||
outputs:
|
outputs:
|
||||||
- new_phase: .data.phase
|
- new_phase: .data.phase
|
||||||
|
|
||||||
|
merge-feature:
|
||||||
|
description: "Merge feature branch to main"
|
||||||
|
depends_on: [transition-to-merge]
|
||||||
|
action: api
|
||||||
|
method: POST
|
||||||
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/merge"
|
||||||
|
body:
|
||||||
|
strategy: "squash"
|
||||||
|
outputs:
|
||||||
|
- merge_commit: .data.commit_sha
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 10: MERGE → RELEASED
|
# PHASE 10: MERGE → RELEASED
|
||||||
# Archive the feature
|
# Archive the feature
|
||||||
# ============================================================
|
# ============================================================
|
||||||
wait-final-deploy:
|
wait-final-deploy:
|
||||||
description: "Wait for merged code to deploy"
|
description: "Wait for merged code to deploy"
|
||||||
depends_on: [transition-to-merge]
|
depends_on: [merge-feature]
|
||||||
action: wait_pipeline
|
action: wait_pipeline
|
||||||
project_id: "{{ .outputs.create-project.project_id }}"
|
project_id: "{{ .outputs.create-project.project_id }}"
|
||||||
max_attempts: 720
|
max_attempts: 720
|
||||||
|
|||||||
402
docs/media-handling-spec.md
Normal file
402
docs/media-handling-spec.md
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
# Media Handling Specification
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Status:** Implementation
|
||||||
|
**Owner:** Platform Team
|
||||||
|
**Last Updated:** 2026-02-08
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This specification defines comprehensive media handling for rdev, enabling generated projects to store and serve user-uploaded files (images, videos, documents) via Google Cloud Storage. The implementation follows rdev's established patterns for infrastructure provisioning, skeleton packages, and component templates.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ rdev Platform Layer │
|
||||||
|
│ ┌───────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ GCS Provisioner (internal/adapter/gcs) │ │
|
||||||
|
│ │ - Creates per-project buckets │ │
|
||||||
|
│ │ - Generates service account credentials │ │
|
||||||
|
│ │ - Stores credentials in rdev credential store │ │
|
||||||
|
│ └───────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓ credentials
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Generated Project Layer │
|
||||||
|
│ ┌───────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ pkg/storage (skeleton package) │ │
|
||||||
|
│ │ - Storage interface abstraction │ │
|
||||||
|
│ │ - GCS implementation │ │
|
||||||
|
│ │ - Memory implementation (testing) │ │
|
||||||
|
│ │ - Signed URL generation │ │
|
||||||
|
│ └───────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ media-upload Component (optional) │ │
|
||||||
|
│ │ - HTTP upload/download/delete endpoints │ │
|
||||||
|
│ │ - File validation │ │
|
||||||
|
│ │ - Path management │ │
|
||||||
|
│ └───────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Google Cloud Storage (External) │
|
||||||
|
│ - Per-project buckets (project-{name}-media) │
|
||||||
|
│ - Per-project service accounts │
|
||||||
|
│ - IAM bindings (objectAdmin) │
|
||||||
|
│ - Lifecycle rules (temp/ auto-cleanup) │
|
||||||
|
│ - CORS configuration │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Patterns
|
||||||
|
|
||||||
|
### Path Conventions
|
||||||
|
|
||||||
|
Projects should organize objects using consistent path patterns:
|
||||||
|
|
||||||
|
```
|
||||||
|
uploads/{user_id}/{timestamp}-{filename} # User-uploaded files
|
||||||
|
avatars/{user_id}.jpg # User avatars
|
||||||
|
temp/{session_id}/{filename} # Temporary files (auto-delete after 24h)
|
||||||
|
public/{category}/{filename} # Public assets (logos, etc.)
|
||||||
|
private/{user_id}/{document_id}/{filename} # Private documents (signed URLs only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Types
|
||||||
|
|
||||||
|
Supported MIME types:
|
||||||
|
- **Images:** `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/svg+xml`
|
||||||
|
- **Videos:** `video/mp4`, `video/webm`, `video/quicktime`
|
||||||
|
- **Documents:** `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.*`
|
||||||
|
- **Archives:** `application/zip`, `application/x-tar`, `application/gzip`
|
||||||
|
|
||||||
|
### Size Limits
|
||||||
|
|
||||||
|
- **Default max upload:** 100MB per file
|
||||||
|
- **Component template:** Configurable via `MAX_UPLOAD_SIZE` env var
|
||||||
|
- **GCS bucket:** No hard limit (quota-based)
|
||||||
|
|
||||||
|
### TTL and Expiry
|
||||||
|
|
||||||
|
Lifecycle rules automatically delete objects:
|
||||||
|
- `temp/*` paths: 24 hours
|
||||||
|
- User can configure custom rules in GCS console
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. **Provisioning Time** (rdev API):
|
||||||
|
- Create GCS bucket: `project-{name}-media`
|
||||||
|
- Create service account: `project-{name}-storage@{gcp-project}.iam.gserviceaccount.com`
|
||||||
|
- Grant IAM role: `roles/storage.objectAdmin` on bucket
|
||||||
|
- Generate service account JSON key
|
||||||
|
- Store credentials in rdev credential store (encrypted)
|
||||||
|
|
||||||
|
2. **Runtime** (generated project):
|
||||||
|
- Read credentials from env vars: `GCS_BUCKET`, `GCS_SERVICE_ACCOUNT_JSON`
|
||||||
|
- Initialize `pkg/storage` client with service account JSON
|
||||||
|
- Client uses ADC (Application Default Credentials) with service account
|
||||||
|
|
||||||
|
### IAM Roles
|
||||||
|
|
||||||
|
Per-project service accounts have **isolated permissions**:
|
||||||
|
- **Bucket-scoped:** `roles/storage.objectAdmin` (CRUD on objects)
|
||||||
|
- **No cross-project access:** Service account A cannot access bucket B
|
||||||
|
- **No IAM permissions:** Cannot modify IAM policies or create resources
|
||||||
|
|
||||||
|
### Signed URLs
|
||||||
|
|
||||||
|
For temporary access without service account credentials:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Generate read URL (1 hour expiry)
|
||||||
|
signedURL, _ := storageClient.SignURL(ctx, "uploads/photo.jpg", time.Hour, false)
|
||||||
|
|
||||||
|
// Generate write URL (15 min expiry) for client-side uploads
|
||||||
|
uploadURL, _ := storageClient.SignURL(ctx, "uploads/photo.jpg", 15*time.Minute, true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Direct browser downloads (avoid proxying through API)
|
||||||
|
- Client-side uploads (POST directly to GCS, not API)
|
||||||
|
- Sharing files with external users (time-limited links)
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
|
||||||
|
Buckets are created with CORS rules:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
MaxAge: 3600
|
||||||
|
Methods: [GET, POST, PUT, DELETE, OPTIONS]
|
||||||
|
Origins: ["https://*.threesix.ai"]
|
||||||
|
ResponseHeaders: ["Content-Type", "ETag"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Projects should override origins for custom domains.
|
||||||
|
|
||||||
|
## API Standards
|
||||||
|
|
||||||
|
### Upload Endpoint
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```http
|
||||||
|
POST /api/media-upload/upload
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
--boundary
|
||||||
|
Content-Disposition: form-data; name="file"; filename="photo.jpg"
|
||||||
|
Content-Type: image/jpeg
|
||||||
|
|
||||||
|
<binary data>
|
||||||
|
--boundary--
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201 Created):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"url": "https://storage.googleapis.com/project-myapp-media/uploads/1706889600-photo.jpg",
|
||||||
|
"path": "uploads/1706889600-photo.jpg",
|
||||||
|
"filename": "photo.jpg",
|
||||||
|
"size": 245678
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400 Bad Request`: File too large, invalid form, missing file
|
||||||
|
- `500 Internal Server Error`: Upload failed (GCS error)
|
||||||
|
|
||||||
|
### Download Endpoint
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```http
|
||||||
|
GET /api/media-upload/download/uploads/1706889600-photo.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (307 Temporary Redirect):**
|
||||||
|
```http
|
||||||
|
HTTP/1.1 307 Temporary Redirect
|
||||||
|
Location: https://storage.googleapis.com/project-myapp-media/uploads/1706889600-photo.jpg?X-Goog-Algorithm=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `404 Not Found`: File does not exist
|
||||||
|
|
||||||
|
### Delete Endpoint
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```http
|
||||||
|
DELETE /api/media-upload/delete/uploads/1706889600-photo.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (204 No Content):**
|
||||||
|
```http
|
||||||
|
HTTP/1.1 204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `404 Not Found`: File does not exist
|
||||||
|
- `500 Internal Server Error`: Delete failed
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Component template should include rate limiting:
|
||||||
|
- **Upload:** 10 requests/minute per IP
|
||||||
|
- **Download:** 100 requests/minute per IP
|
||||||
|
- **Delete:** 10 requests/minute per IP
|
||||||
|
|
||||||
|
Use `github.com/go-chi/httprate` middleware.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
**Platform (GCS Provisioner):**
|
||||||
|
- `TestSanitizeForGCP`: Validate bucket name sanitization
|
||||||
|
- `TestBucketNameFor`: Validate bucket naming convention
|
||||||
|
- `TestServiceAccountEmailFor`: Validate service account email format
|
||||||
|
|
||||||
|
**Skeleton (pkg/storage):**
|
||||||
|
- `TestMemoryStorage`: Verify in-memory implementation
|
||||||
|
- `TestUploadOptions`: Validate option handling
|
||||||
|
- `TestErrorHandling`: Verify error types (ErrNotFound, etc.)
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
**GCS Provisioner:**
|
||||||
|
```go
|
||||||
|
// Requires GCS_TEST_PROJECT_ID and GCS_TEST_CREDENTIALS_PATH env vars
|
||||||
|
func TestGCSProvisionerIntegration(t *testing.T) {
|
||||||
|
// Create bucket
|
||||||
|
creds, err := provisioner.CreateProjectBucket(ctx, "test-project-123")
|
||||||
|
// Verify bucket exists in GCS
|
||||||
|
// Verify service account exists
|
||||||
|
// Verify IAM bindings
|
||||||
|
// Cleanup
|
||||||
|
provisioner.DeleteProjectBucket(ctx, "test-project-123", true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**pkg/storage:**
|
||||||
|
```go
|
||||||
|
// Requires test GCS bucket
|
||||||
|
func TestGCSStorageIntegration(t *testing.T) {
|
||||||
|
// Upload file
|
||||||
|
// Verify file exists
|
||||||
|
// Download file
|
||||||
|
// Delete file
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests (Cookbook Tree)
|
||||||
|
|
||||||
|
See `cookbooks/trees/media-upload-flow.yaml`:
|
||||||
|
1. Create project
|
||||||
|
2. Provision GCS component
|
||||||
|
3. Add media-upload service component
|
||||||
|
4. Wait for CI/CD pipeline
|
||||||
|
5. Test upload endpoint
|
||||||
|
6. Verify file in GCS bucket
|
||||||
|
7. Test download endpoint
|
||||||
|
8. Cleanup (delete project, bucket)
|
||||||
|
|
||||||
|
### Mocks
|
||||||
|
|
||||||
|
For projects using `pkg/storage`, provide mock implementation:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/storage/mock.go (generated projects can create this)
|
||||||
|
type MockStorage struct {
|
||||||
|
UploadFunc func(ctx context.Context, path string, r io.Reader, opts UploadOptions) (string, error)
|
||||||
|
DownloadFunc func(ctx context.Context, path string) (io.ReadCloser, *ObjectAttrs, error)
|
||||||
|
// ... other methods
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational Concerns
|
||||||
|
|
||||||
|
### Bucket Lifecycle
|
||||||
|
|
||||||
|
**Creation:**
|
||||||
|
- Triggered by `POST /projects/{id}/components` with `type=gcs`
|
||||||
|
- Returns immediately after bucket + credentials created
|
||||||
|
- Credentials stored in rdev credential store
|
||||||
|
|
||||||
|
**Deletion:**
|
||||||
|
- Triggered by `DELETE /project/{id}`
|
||||||
|
- Deletes all objects first (if `force=true`)
|
||||||
|
- Deletes bucket
|
||||||
|
- Deletes service account and keys
|
||||||
|
|
||||||
|
**Orphan Prevention:**
|
||||||
|
- Project deletion hook cleans up all infra (postgres, redis, gcs)
|
||||||
|
- If cleanup fails, logs warning but continues (manual cleanup required)
|
||||||
|
|
||||||
|
### Cost Management
|
||||||
|
|
||||||
|
**Estimates (per project):**
|
||||||
|
- **Storage:** $0.020/GB/month (Standard class, US region)
|
||||||
|
- **Operations:** $0.005/10k reads, $0.05/10k writes
|
||||||
|
- **Network:** $0.12/GB egress (to internet)
|
||||||
|
|
||||||
|
**Typical project (1k users, 10GB media):**
|
||||||
|
- Storage: $0.20/month
|
||||||
|
- Operations: $0.10/month (10k reads, 1k writes)
|
||||||
|
- **Total:** ~$0.30/month
|
||||||
|
|
||||||
|
**Cost optimization:**
|
||||||
|
- Use lifecycle rules to auto-delete temp files
|
||||||
|
- Serve images via CDN (reduce GCS egress)
|
||||||
|
- Use signed URLs (avoid API proxy overhead)
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
**Metrics to track (Prometheus):**
|
||||||
|
- `rdev_gcs_buckets_total`: Total buckets created
|
||||||
|
- `rdev_gcs_provision_duration_seconds`: Bucket creation latency
|
||||||
|
- `rdev_gcs_provision_errors_total`: Provisioning failures
|
||||||
|
- `storage_upload_duration_seconds`: Upload latency (in generated projects)
|
||||||
|
- `storage_upload_errors_total`: Upload failures
|
||||||
|
- `storage_upload_bytes_total`: Total bytes uploaded
|
||||||
|
|
||||||
|
**Logs to monitor:**
|
||||||
|
- Provisioning errors (insufficient permissions, quota exceeded)
|
||||||
|
- Upload errors (file too large, invalid content type)
|
||||||
|
- Download 404s (broken links, deleted files)
|
||||||
|
|
||||||
|
### Quotas
|
||||||
|
|
||||||
|
**GCS limits:**
|
||||||
|
- **Bucket creation:** 100/day per GCP project (sufficient for small deployments)
|
||||||
|
- **Service accounts:** 100 per GCP project (shared quota with other services)
|
||||||
|
- **IAM policies:** 1500 bindings per bucket (one per service account)
|
||||||
|
|
||||||
|
**Scaling beyond limits:**
|
||||||
|
- Use multiple GCP projects (shard by project ID hash)
|
||||||
|
- Use single bucket with path prefixes (less isolation, not recommended)
|
||||||
|
|
||||||
|
### Backup and Recovery
|
||||||
|
|
||||||
|
**Bucket versioning:**
|
||||||
|
- Enable versioning for critical projects: `gsutil versioning set on gs://bucket`
|
||||||
|
- Allows recovery from accidental deletions
|
||||||
|
|
||||||
|
**Cross-region replication:**
|
||||||
|
- For high-availability projects, enable dual-region buckets
|
||||||
|
- Example: `Location: "US"` → `Location: "NAM4"` (multi-region)
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Platform - GCS Provisioner ✅
|
||||||
|
- Define `port.StorageProvisioner` interface
|
||||||
|
- Implement `adapter/gcs.Provisioner`
|
||||||
|
- Wire into `ComponentService`
|
||||||
|
- Add GCS config to `cmd/rdev-api`
|
||||||
|
|
||||||
|
### Phase 2: Skeleton - pkg/storage ✅
|
||||||
|
- Define `storage.Storage` interface
|
||||||
|
- Implement `GCSStorage`
|
||||||
|
- Implement `MemoryStorage` (testing)
|
||||||
|
- Add to skeleton templates
|
||||||
|
|
||||||
|
### Phase 3: Component - media-upload ✅
|
||||||
|
- Create component template with upload/download handlers
|
||||||
|
- Add Woodpecker build/deploy steps
|
||||||
|
- Add to component registry
|
||||||
|
|
||||||
|
### Phase 4: Testing - Cookbook Tree ✅
|
||||||
|
- Write E2E cookbook tree
|
||||||
|
- Run in CI pipeline
|
||||||
|
- Document in guides
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] Service accounts have minimal IAM roles (objectAdmin only)
|
||||||
|
- [ ] Credentials stored encrypted in rdev credential store
|
||||||
|
- [ ] Bucket names do not expose sensitive project details
|
||||||
|
- [ ] CORS origins restricted to *.threesix.ai (or custom domains)
|
||||||
|
- [ ] Signed URLs have reasonable expiry times (≤1 hour for reads, ≤15 min for writes)
|
||||||
|
- [ ] File size limits enforced (prevent DoS via large uploads)
|
||||||
|
- [ ] Content-Type validation (prevent malicious file uploads)
|
||||||
|
- [ ] Public read ACLs only set when explicitly requested (`Public: true`)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Multi-Backend Support:** Add S3, MinIO, R2 adapters
|
||||||
|
2. **Image Processing:** Automatic thumbnail generation, format conversion
|
||||||
|
3. **CDN Integration:** Cloudflare R2 + cache purging
|
||||||
|
4. **Quota Management:** Per-project storage limits, alerting
|
||||||
|
5. **Virus Scanning:** ClamAV integration for uploaded files
|
||||||
|
6. **Resumable Uploads:** For large files (>100MB)
|
||||||
|
7. **Streaming:** Direct browser-to-GCS uploads (bypass API)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **GCS Client Docs:** https://cloud.google.com/go/docs/reference/cloud.google.com/go/storage/latest
|
||||||
|
- **IAM Best Practices:** https://cloud.google.com/iam/docs/best-practices
|
||||||
|
- **Signed URLs:** https://cloud.google.com/storage/docs/access-control/signed-urls
|
||||||
|
- **rdev Postgres Provisioner:** `internal/adapter/postgres/provisioner.go`
|
||||||
|
- **rdev Redis Provisioner:** `internal/adapter/redis/provisioner.go`
|
||||||
30
go.mod
30
go.mod
@ -25,22 +25,41 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cel.dev/expr v0.24.0 // indirect
|
||||||
|
cloud.google.com/go v0.123.0 // indirect
|
||||||
|
cloud.google.com/go/auth v0.18.1 // indirect
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
|
cloud.google.com/go/iam v1.5.3 // indirect
|
||||||
|
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||||
|
cloud.google.com/go/storage v1.59.2 // indirect
|
||||||
github.com/42wim/httpsig v1.2.3 // indirect
|
github.com/42wim/httpsig v1.2.3 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
@ -50,27 +69,36 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/term v0.39.0 // indirect
|
golang.org/x/term v0.39.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
google.golang.org/api v0.265.0 // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||||
google.golang.org/grpc v1.78.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
|
|||||||
55
go.sum
55
go.sum
@ -1,7 +1,29 @@
|
|||||||
|
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||||
|
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||||
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
|
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||||
|
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
|
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||||
|
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||||
|
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||||
|
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||||
|
cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
|
||||||
|
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||||
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
|
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
|
||||||
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/bdpiprava/scalar-go v0.13.0 h1:TuhOwYalDpLAziohyEwZlq4PqtEJ+6P/V92dDCdja9k=
|
github.com/bdpiprava/scalar-go v0.13.0 h1:TuhOwYalDpLAziohyEwZlq4PqtEJ+6P/V92dDCdja9k=
|
||||||
@ -16,6 +38,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
|||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -27,12 +51,21 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@ -55,8 +88,14 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||||
@ -94,6 +133,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns
|
|||||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@ -114,6 +155,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
|||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||||
|
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
@ -124,6 +167,12 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
|||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||||
@ -182,10 +231,16 @@ golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
|||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
|
||||||
|
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
|
||||||
|
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
|
||||||
|
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
261
internal/adapter/gcs/provisioner.go
Normal file
261
internal/adapter/gcs/provisioner.go
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
package gcs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cloud.google.com/go/storage"
|
||||||
|
"google.golang.org/api/iam/v1"
|
||||||
|
"google.golang.org/api/iterator"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provisioner implements port.StorageProvisioner using Google Cloud Storage.
|
||||||
|
type Provisioner struct {
|
||||||
|
storageClient *storage.Client
|
||||||
|
iamService *iam.Service
|
||||||
|
gcpProjectID string // GCP project (e.g., "threesix-prod")
|
||||||
|
location string // Bucket location (e.g., "US")
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds GCS provisioner configuration.
|
||||||
|
type Config struct {
|
||||||
|
GoogleProjectID string // GCP project ID where buckets will be created
|
||||||
|
Location string // Bucket location (default: "US")
|
||||||
|
CredentialsPath string // Path to service account JSON (empty = ADC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProvisioner creates a new GCS bucket provisioner.
|
||||||
|
func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Apply defaults
|
||||||
|
if cfg.Location == "" {
|
||||||
|
cfg.Location = "US"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create storage client
|
||||||
|
// Note: If CredentialsPath is provided, it should be set in GOOGLE_APPLICATION_CREDENTIALS
|
||||||
|
// env var before starting the service. This avoids deprecated WithCredentialsFile/JSON.
|
||||||
|
// The SDK will automatically use ADC (Application Default Credentials).
|
||||||
|
var opts []option.ClientOption
|
||||||
|
if cfg.CredentialsPath != "" {
|
||||||
|
// Set environment variable for this process so SDK uses ADC
|
||||||
|
if err := os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", cfg.CredentialsPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("set GOOGLE_APPLICATION_CREDENTIALS: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storageClient, err := storage.NewClient(ctx, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create storage client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create IAM service for service account management
|
||||||
|
iamService, err := iam.NewService(ctx, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create IAM service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Provisioner{
|
||||||
|
storageClient: storageClient,
|
||||||
|
iamService: iamService,
|
||||||
|
gcpProjectID: cfg.GoogleProjectID,
|
||||||
|
location: cfg.Location,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
if err := p.TestConnection(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("gcs connection test failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProjectBucket provisions a GCS bucket with service account and IAM bindings.
|
||||||
|
func (p *Provisioner) CreateProjectBucket(ctx context.Context, projectID string) (*domain.StorageCredentials, error) {
|
||||||
|
bucketName := p.bucketNameFor(projectID)
|
||||||
|
saEmail := p.serviceAccountEmailFor(projectID)
|
||||||
|
|
||||||
|
// 1. Check if bucket already exists
|
||||||
|
bucket := p.storageClient.Bucket(bucketName)
|
||||||
|
if _, err := bucket.Attrs(ctx); err == nil {
|
||||||
|
return nil, fmt.Errorf("bucket already exists: %s", bucketName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create bucket with lifecycle rules
|
||||||
|
if err := bucket.Create(ctx, p.gcpProjectID, &storage.BucketAttrs{
|
||||||
|
Location: p.location,
|
||||||
|
StorageClass: "STANDARD",
|
||||||
|
Lifecycle: storage.Lifecycle{
|
||||||
|
Rules: []storage.LifecycleRule{
|
||||||
|
// Delete temp files after 24 hours
|
||||||
|
{
|
||||||
|
Action: storage.LifecycleAction{Type: "Delete"},
|
||||||
|
Condition: storage.LifecycleCondition{
|
||||||
|
MatchesPrefix: []string{"temp/"},
|
||||||
|
AgeInDays: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CORS: []storage.CORS{
|
||||||
|
{
|
||||||
|
MaxAge: 3600 * time.Second,
|
||||||
|
Methods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
Origins: []string{"https://*.threesix.ai"},
|
||||||
|
ResponseHeaders: []string{"Content-Type", "ETag"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("create bucket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create service account
|
||||||
|
accountID := fmt.Sprintf("project-%s-storage", sanitizeForGCP(projectID))
|
||||||
|
sa, err := p.iamService.Projects.ServiceAccounts.Create(
|
||||||
|
fmt.Sprintf("projects/%s", p.gcpProjectID),
|
||||||
|
&iam.CreateServiceAccountRequest{
|
||||||
|
AccountId: accountID,
|
||||||
|
ServiceAccount: &iam.ServiceAccount{
|
||||||
|
DisplayName: fmt.Sprintf("Storage for %s", projectID),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).Context(ctx).Do()
|
||||||
|
if err != nil {
|
||||||
|
// Rollback bucket
|
||||||
|
_ = bucket.Delete(ctx)
|
||||||
|
return nil, fmt.Errorf("create service account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Grant bucket permissions to service account
|
||||||
|
policy, err := bucket.IAM().Policy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get bucket policy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy.Add(saEmail, "roles/storage.objectAdmin")
|
||||||
|
if err := bucket.IAM().SetPolicy(ctx, policy); err != nil {
|
||||||
|
return nil, fmt.Errorf("set bucket policy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create service account key
|
||||||
|
key, err := p.iamService.Projects.ServiceAccounts.Keys.Create(
|
||||||
|
sa.Name,
|
||||||
|
&iam.CreateServiceAccountKeyRequest{},
|
||||||
|
).Context(ctx).Do()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create service account key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Return credentials
|
||||||
|
return &domain.StorageCredentials{
|
||||||
|
ProjectID: projectID,
|
||||||
|
BucketName: bucketName,
|
||||||
|
ServiceAccountJSON: key.PrivateKeyData, // Base64-encoded JSON
|
||||||
|
Location: p.location,
|
||||||
|
PublicURLPrefix: fmt.Sprintf("https://storage.googleapis.com/%s", bucketName),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProjectBucket removes bucket and service account.
|
||||||
|
func (p *Provisioner) DeleteProjectBucket(ctx context.Context, projectID string, force bool) error {
|
||||||
|
bucketName := p.bucketNameFor(projectID)
|
||||||
|
bucket := p.storageClient.Bucket(bucketName)
|
||||||
|
|
||||||
|
// Delete all objects if force=true
|
||||||
|
if force {
|
||||||
|
it := bucket.Objects(ctx, nil)
|
||||||
|
for {
|
||||||
|
attrs, err := it.Next()
|
||||||
|
if err == iterator.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list objects: %w", err)
|
||||||
|
}
|
||||||
|
if err := bucket.Object(attrs.Name).Delete(ctx); err != nil {
|
||||||
|
p.logger.Warn("failed to delete object", "name", attrs.Name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete bucket
|
||||||
|
if err := bucket.Delete(ctx); err != nil {
|
||||||
|
return fmt.Errorf("delete bucket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete service account
|
||||||
|
saEmail := p.serviceAccountEmailFor(projectID)
|
||||||
|
saName := fmt.Sprintf("projects/%s/serviceAccounts/%s", p.gcpProjectID, saEmail)
|
||||||
|
if _, err := p.iamService.Projects.ServiceAccounts.Delete(saName).Context(ctx).Do(); err != nil {
|
||||||
|
p.logger.Warn("failed to delete service account", "email", saEmail, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjectBucket checks if bucket exists and returns metadata.
|
||||||
|
func (p *Provisioner) GetProjectBucket(ctx context.Context, projectID string) (*domain.StorageCredentials, error) {
|
||||||
|
bucketName := p.bucketNameFor(projectID)
|
||||||
|
bucket := p.storageClient.Bucket(bucketName)
|
||||||
|
|
||||||
|
attrs, err := bucket.Attrs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err == storage.ErrBucketNotExist {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get bucket attrs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.StorageCredentials{
|
||||||
|
ProjectID: projectID,
|
||||||
|
BucketName: bucketName,
|
||||||
|
Location: attrs.Location,
|
||||||
|
PublicURLPrefix: fmt.Sprintf("https://storage.googleapis.com/%s", bucketName),
|
||||||
|
CreatedAt: attrs.Created,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection verifies GCS connectivity.
|
||||||
|
func (p *Provisioner) TestConnection(ctx context.Context) error {
|
||||||
|
// Try to list buckets as connection test
|
||||||
|
it := p.storageClient.Buckets(ctx, p.gcpProjectID)
|
||||||
|
if _, err := it.Next(); err != nil && err != iterator.Done {
|
||||||
|
return fmt.Errorf("list buckets failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close cleans up resources.
|
||||||
|
func (p *Provisioner) Close() error {
|
||||||
|
return p.storageClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func (p *Provisioner) bucketNameFor(projectID string) string {
|
||||||
|
return fmt.Sprintf("project-%s-media", sanitizeForGCP(projectID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provisioner) serviceAccountEmailFor(projectID string) string {
|
||||||
|
return fmt.Sprintf("project-%s-storage@%s.iam.gserviceaccount.com",
|
||||||
|
sanitizeForGCP(projectID), p.gcpProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeForGCP(s string) string {
|
||||||
|
// GCP resource names: lowercase alphanumeric and hyphens only
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
s = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(s, "-")
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
return s
|
||||||
|
}
|
||||||
72
internal/adapter/gcs/provisioner_test.go
Normal file
72
internal/adapter/gcs/provisioner_test.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package gcs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeForGCP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"my-project", "my-project"},
|
||||||
|
{"My_Project", "my-project"},
|
||||||
|
{"test@123", "test-123"},
|
||||||
|
{"--edge--", "edge"},
|
||||||
|
{"UPPERCASE", "uppercase"},
|
||||||
|
{"spaces and stuff", "spaces-and-stuff"},
|
||||||
|
{"special!@#$%chars", "special-chars"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
result := sanitizeForGCP(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("sanitizeForGCP(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketNameFor(t *testing.T) {
|
||||||
|
p := &Provisioner{}
|
||||||
|
tests := []struct {
|
||||||
|
projectID string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"my-app", "project-my-app-media"},
|
||||||
|
{"Test_App", "project-test-app-media"},
|
||||||
|
{"app123", "project-app123-media"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.projectID, func(t *testing.T) {
|
||||||
|
result := p.bucketNameFor(tt.projectID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("bucketNameFor(%q) = %q, want %q", tt.projectID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceAccountEmailFor(t *testing.T) {
|
||||||
|
p := &Provisioner{
|
||||||
|
gcpProjectID: "threesix-prod",
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
projectID string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"my-app", "project-my-app-storage@threesix-prod.iam.gserviceaccount.com"},
|
||||||
|
{"Test_App", "project-test-app-storage@threesix-prod.iam.gserviceaccount.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.projectID, func(t *testing.T) {
|
||||||
|
result := p.serviceAccountEmailFor(tt.projectID)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("serviceAccountEmailFor(%q) = %q, want %q", tt.projectID, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ steps:
|
|||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
cache: true
|
cache: true
|
||||||
skip-tls-verify: true
|
skip-tls-verify: true
|
||||||
failure: retry
|
failure: ignore
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
build-{{COMPONENT_NAME}}:
|
build-{{COMPONENT_NAME}}:
|
||||||
depends_on: [preflight]
|
depends_on: [preflight]
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
failure: retry
|
failure: ignore
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
build-{{COMPONENT_NAME}}:
|
build-{{COMPONENT_NAME}}:
|
||||||
depends_on: [preflight]
|
depends_on: [preflight]
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
failure: retry
|
failure: ignore
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
build-{{COMPONENT_NAME}}:
|
build-{{COMPONENT_NAME}}:
|
||||||
depends_on: [preflight]
|
depends_on: [preflight]
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
failure: retry
|
failure: ignore
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
build-{{COMPONENT_NAME}}:
|
build-{{COMPONENT_NAME}}:
|
||||||
depends_on: [deps]
|
depends_on: [deps]
|
||||||
image: golang:1.23-alpine
|
image: golang:1.25-alpine
|
||||||
commands:
|
commands:
|
||||||
- cd cli/{{COMPONENT_NAME}}
|
- cd cli/{{COMPONENT_NAME}}
|
||||||
- go mod download
|
- go mod download
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module {{GO_MODULE}}/cli/{{COMPONENT_NAME}}
|
module {{GO_MODULE}}/cli/{{COMPONENT_NAME}}
|
||||||
|
|
||||||
go 1.23
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
build-{{COMPONENT_NAME}}:
|
build-{{COMPONENT_NAME}}:
|
||||||
depends_on: [preflight]
|
depends_on: [preflight]
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
failure: retry
|
failure: ignore
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module {{GO_MODULE}}/services/{{COMPONENT_NAME}}
|
module {{GO_MODULE}}/services/{{COMPONENT_NAME}}
|
||||||
|
|
||||||
go 1.23
|
go 1.25
|
||||||
|
|
||||||
require {{GO_MODULE}}/pkg v0.0.0
|
require {{GO_MODULE}}/pkg v0.0.0
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
build-{{COMPONENT_NAME}}:
|
build-{{COMPONENT_NAME}}:
|
||||||
depends_on: [preflight]
|
depends_on: [preflight]
|
||||||
image: woodpeckerci/plugin-kaniko
|
image: woodpeckerci/plugin-kaniko
|
||||||
failure: retry
|
failure: ignore
|
||||||
settings:
|
settings:
|
||||||
registry: registry.threesix.ai
|
registry: registry.threesix.ai
|
||||||
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module {{GO_MODULE}}/workers/{{COMPONENT_NAME}}
|
module {{GO_MODULE}}/workers/{{COMPONENT_NAME}}
|
||||||
|
|
||||||
go 1.23
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
{{GO_MODULE}}/pkg v0.0.0
|
{{GO_MODULE}}/pkg v0.0.0
|
||||||
|
|||||||
@ -9,7 +9,7 @@ steps:
|
|||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
cache: true
|
cache: true
|
||||||
skip-tls-verify: true
|
skip-tls-verify: true
|
||||||
failure: retry
|
failure: ignore
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|||||||
@ -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:
|
||||||
@ -23,7 +23,7 @@ steps:
|
|||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
cache: true
|
cache: true
|
||||||
skip-tls-verify: true
|
skip-tls-verify: true
|
||||||
failure: retry
|
failure: ignore
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.22-alpine AS build
|
FROM golang:1.25-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ COPY . .
|
|||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM alpine:latest
|
FROM alpine:3.19
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
module github.com/orchard9/{{PROJECT_NAME}}
|
module github.com/orchard9/{{PROJECT_NAME}}
|
||||||
|
|
||||||
go 1.22
|
go 1.25
|
||||||
|
|
||||||
require github.com/go-chi/chi/v5 v5.0.12
|
require github.com/go-chi/chi/v5 v5.0.12
|
||||||
|
|||||||
@ -9,7 +9,7 @@ clone:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
deps:
|
deps:
|
||||||
image: golang:1.23
|
image: golang:1.25
|
||||||
commands:
|
commands:
|
||||||
- go work sync
|
- go work sync
|
||||||
- |
|
- |
|
||||||
@ -86,7 +86,7 @@ steps:
|
|||||||
export-openapi:
|
export-openapi:
|
||||||
depends_on: [services-deployed]
|
depends_on: [services-deployed]
|
||||||
failure: ignore
|
failure: ignore
|
||||||
image: golang:1.23
|
image: golang:1.25
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
echo "==> Exporting OpenAPI specs from services"
|
echo "==> Exporting OpenAPI specs from services"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
go 1.23
|
go 1.25
|
||||||
|
|
||||||
use ./pkg
|
use ./pkg
|
||||||
// Component modules will be added below
|
// Component modules will be added below
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module {{GO_MODULE}}/pkg
|
module {{GO_MODULE}}/pkg
|
||||||
|
|
||||||
go 1.23
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bdpiprava/scalar-go v0.13.0
|
github.com/bdpiprava/scalar-go v0.13.0
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const (
|
|||||||
// Infrastructure component types - these trigger provisioning, not scaffolding.
|
// Infrastructure component types - these trigger provisioning, not scaffolding.
|
||||||
ComponentTypePostgres ComponentType = "postgres"
|
ComponentTypePostgres ComponentType = "postgres"
|
||||||
ComponentTypeRedis ComponentType = "redis"
|
ComponentTypeRedis ComponentType = "redis"
|
||||||
|
ComponentTypeGCS ComponentType = "gcs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidComponentTypes lists all valid component types.
|
// ValidComponentTypes lists all valid component types.
|
||||||
@ -28,6 +29,7 @@ var ValidComponentTypes = []ComponentType{
|
|||||||
ComponentTypeCLI,
|
ComponentTypeCLI,
|
||||||
ComponentTypePostgres,
|
ComponentTypePostgres,
|
||||||
ComponentTypeRedis,
|
ComponentTypeRedis,
|
||||||
|
ComponentTypeGCS,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidComponentType checks if a string is a valid component type.
|
// IsValidComponentType checks if a string is a valid component type.
|
||||||
@ -96,10 +98,10 @@ func (c ComponentType) IsAppComponent() bool {
|
|||||||
return c == ComponentTypeAppAstro || c == ComponentTypeAppReact || c == ComponentTypeAppNextJS
|
return c == ComponentTypeAppAstro || c == ComponentTypeAppReact || c == ComponentTypeAppNextJS
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInfraComponent returns true if this component type is infrastructure (database, cache).
|
// IsInfraComponent returns true if this component type is infrastructure (database, cache, storage).
|
||||||
// Infrastructure components trigger provisioning instead of template scaffolding.
|
// Infrastructure components trigger provisioning instead of template scaffolding.
|
||||||
func (c ComponentType) IsInfraComponent() bool {
|
func (c ComponentType) IsInfraComponent() bool {
|
||||||
return c == ComponentTypePostgres || c == ComponentTypeRedis
|
return c == ComponentTypePostgres || c == ComponentTypeRedis || c == ComponentTypeGCS
|
||||||
}
|
}
|
||||||
|
|
||||||
// componentNameRegex validates component names (slug format: lowercase, alphanumeric, dashes).
|
// componentNameRegex validates component names (slug format: lowercase, alphanumeric, dashes).
|
||||||
|
|||||||
@ -172,6 +172,7 @@ func TestValidComponentTypes(t *testing.T) {
|
|||||||
ComponentTypeCLI,
|
ComponentTypeCLI,
|
||||||
ComponentTypePostgres,
|
ComponentTypePostgres,
|
||||||
ComponentTypeRedis,
|
ComponentTypeRedis,
|
||||||
|
ComponentTypeGCS,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ValidComponentTypes) != len(expected) {
|
if len(ValidComponentTypes) != len(expected) {
|
||||||
|
|||||||
@ -36,6 +36,7 @@ const (
|
|||||||
CredentialCategoryDatabase = "database"
|
CredentialCategoryDatabase = "database"
|
||||||
CredentialCategoryRegistry = "registry"
|
CredentialCategoryRegistry = "registry"
|
||||||
CredentialCategoryWorker = "worker"
|
CredentialCategoryWorker = "worker"
|
||||||
|
CredentialCategoryStorage = "storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Known credential keys.
|
// Known credential keys.
|
||||||
@ -55,4 +56,8 @@ const (
|
|||||||
|
|
||||||
// Registry
|
// Registry
|
||||||
CredKeyRegistryURL = "REGISTRY_URL"
|
CredKeyRegistryURL = "REGISTRY_URL"
|
||||||
|
|
||||||
|
// GCS
|
||||||
|
CredKeyGCSBucket = "GCS_BUCKET"
|
||||||
|
CredKeyGCSServiceAccountJSON = "GCS_SERVICE_ACCOUNT_JSON"
|
||||||
)
|
)
|
||||||
|
|||||||
13
internal/domain/storage.go
Normal file
13
internal/domain/storage.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StorageCredentials holds GCS bucket access credentials for a project.
|
||||||
|
type StorageCredentials struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
BucketName string `json:"bucket_name"` // e.g., "project-myapp-media"
|
||||||
|
ServiceAccountJSON string `json:"service_account_json"` // Base64-encoded JSON key
|
||||||
|
Location string `json:"location"` // e.g., "US", "EU"
|
||||||
|
PublicURLPrefix string `json:"public_url_prefix"` // https://storage.googleapis.com/{bucket}
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
24
internal/port/storage_provisioner.go
Normal file
24
internal/port/storage_provisioner.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StorageProvisioner manages object storage buckets for projects.
|
||||||
|
type StorageProvisioner interface {
|
||||||
|
// CreateProjectBucket creates a GCS bucket for the project with isolated permissions.
|
||||||
|
// Returns credentials including bucket name, service account JSON, and public URL.
|
||||||
|
CreateProjectBucket(ctx context.Context, projectID string) (*domain.StorageCredentials, error)
|
||||||
|
|
||||||
|
// DeleteProjectBucket removes the bucket and all objects.
|
||||||
|
// If force is true, deletes all objects first; otherwise fails if bucket not empty.
|
||||||
|
DeleteProjectBucket(ctx context.Context, projectID string, force bool) error
|
||||||
|
|
||||||
|
// GetProjectBucket checks if bucket exists and returns credentials (without secrets).
|
||||||
|
GetProjectBucket(ctx context.Context, projectID string) (*domain.StorageCredentials, error)
|
||||||
|
|
||||||
|
// TestConnection verifies GCS client can connect and has necessary permissions.
|
||||||
|
TestConnection(ctx context.Context) error
|
||||||
|
}
|
||||||
@ -41,10 +41,11 @@ type ComponentService struct {
|
|||||||
defaultGitOwner string
|
defaultGitOwner string
|
||||||
registryURL string
|
registryURL string
|
||||||
|
|
||||||
// Infrastructure provisioners (optional - needed for postgres/redis components)
|
// Infrastructure provisioners (optional - needed for postgres/redis/gcs components)
|
||||||
dbProvisioner port.DatabaseProvisioner
|
dbProvisioner port.DatabaseProvisioner
|
||||||
cacheProvisioner port.CacheProvisioner
|
cacheProvisioner port.CacheProvisioner
|
||||||
credentialStore port.CredentialStore
|
storageProvisioner port.StorageProvisioner
|
||||||
|
credentialStore port.CredentialStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComponentServiceConfig configures the component service.
|
// ComponentServiceConfig configures the component service.
|
||||||
@ -86,6 +87,12 @@ func (s *ComponentService) WithCacheProvisioner(p port.CacheProvisioner) *Compon
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithStorageProvisioner adds a storage provisioner for gcs component support.
|
||||||
|
func (s *ComponentService) WithStorageProvisioner(p port.StorageProvisioner) *ComponentService {
|
||||||
|
s.storageProvisioner = p
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// WithCredentialStore adds a credential store for storing provisioned credentials.
|
// WithCredentialStore adds a credential store for storing provisioned credentials.
|
||||||
func (s *ComponentService) WithCredentialStore(cs port.CredentialStore) *ComponentService {
|
func (s *ComponentService) WithCredentialStore(cs port.CredentialStore) *ComponentService {
|
||||||
s.credentialStore = cs
|
s.credentialStore = cs
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/orchard9/rdev/internal/logging"
|
"github.com/orchard9/rdev/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// addInfraComponent provisions an infrastructure component (postgres, redis).
|
// addInfraComponent provisions an infrastructure component (postgres, redis, gcs).
|
||||||
// Unlike code components, these don't scaffold files - they provision resources.
|
// Unlike code components, these don't scaffold files - they provision resources.
|
||||||
func (s *ComponentService) addInfraComponent(ctx context.Context, projectID string, componentType domain.ComponentType, name string) (*domain.Component, error) {
|
func (s *ComponentService) addInfraComponent(ctx context.Context, projectID string, componentType domain.ComponentType, name string) (*domain.Component, error) {
|
||||||
switch componentType {
|
switch componentType {
|
||||||
@ -16,6 +16,8 @@ func (s *ComponentService) addInfraComponent(ctx context.Context, projectID stri
|
|||||||
return s.provisionPostgres(ctx, projectID, name)
|
return s.provisionPostgres(ctx, projectID, name)
|
||||||
case domain.ComponentTypeRedis:
|
case domain.ComponentTypeRedis:
|
||||||
return s.provisionRedis(ctx, projectID, name)
|
return s.provisionRedis(ctx, projectID, name)
|
||||||
|
case domain.ComponentTypeGCS:
|
||||||
|
return s.provisionGCS(ctx, projectID, name)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: unknown infrastructure type %s", domain.ErrInvalidComponentType, componentType)
|
return nil, fmt.Errorf("%w: unknown infrastructure type %s", domain.ErrInvalidComponentType, componentType)
|
||||||
}
|
}
|
||||||
@ -130,6 +132,59 @@ func (s *ComponentService) provisionRedis(ctx context.Context, projectID, name s
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// provisionGCS provisions a GCS bucket for the project.
|
||||||
|
func (s *ComponentService) provisionGCS(ctx context.Context, projectID, name string) (*domain.Component, error) {
|
||||||
|
if s.storageProvisioner == nil {
|
||||||
|
return nil, fmt.Errorf("storage provisioner not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bucket already exists for this project
|
||||||
|
existing, err := s.storageProvisioner.GetProjectBucket(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check existing bucket: %w", err)
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return nil, fmt.Errorf("%w: gcs already provisioned for project %s", domain.ErrDuplicateComponent, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the bucket
|
||||||
|
creds, err := s.storageProvisioner.CreateProjectBucket(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to provision storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store credentials if credential store is available
|
||||||
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
|
if s.credentialStore != nil {
|
||||||
|
if err := s.storeCredential(ctx, projectID, "storage", domain.CredKeyGCSBucket, creds.BucketName); err != nil {
|
||||||
|
// Rollback on credential storage failure
|
||||||
|
log.Error("failed to store GCS_BUCKET, rolling back", logging.FieldError, err)
|
||||||
|
if rollbackErr := s.storageProvisioner.DeleteProjectBucket(ctx, projectID, true); rollbackErr != nil {
|
||||||
|
log.Error("failed to rollback bucket", logging.FieldError, rollbackErr)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to store credentials: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.storeCredential(ctx, projectID, "storage", domain.CredKeyGCSServiceAccountJSON, creds.ServiceAccountJSON); err != nil {
|
||||||
|
log.Warn("failed to store GCS_SERVICE_ACCOUNT_JSON", logging.FieldError, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("gcs component provisioned",
|
||||||
|
logging.FieldProjectID, projectID,
|
||||||
|
"name", name,
|
||||||
|
"bucket", creds.BucketName,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &domain.Component{
|
||||||
|
Type: domain.ComponentTypeGCS,
|
||||||
|
Name: name,
|
||||||
|
Path: "infra/gcs",
|
||||||
|
Port: 0,
|
||||||
|
Template: "gcs",
|
||||||
|
Dependencies: []string{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// storeCredential stores a project-scoped credential.
|
// storeCredential stores a project-scoped credential.
|
||||||
func (s *ComponentService) storeCredential(ctx context.Context, projectID, category, key, value string) error {
|
func (s *ComponentService) storeCredential(ctx context.Context, projectID, category, key, value string) error {
|
||||||
scopedKey := projectID + ":" + key
|
scopedKey := projectID + ":" + key
|
||||||
|
|||||||
@ -23,18 +23,19 @@ func ValidateProjectName(name string) error {
|
|||||||
// It coordinates git repo creation, DNS, CI activation, template seeding, deployment,
|
// It coordinates git repo creation, DNS, CI activation, template seeding, deployment,
|
||||||
// and database/cache provisioning.
|
// and database/cache provisioning.
|
||||||
type ProjectInfraService struct {
|
type ProjectInfraService struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
gitRepo port.GitRepository
|
gitRepo port.GitRepository
|
||||||
dns port.DNSProvider
|
dns port.DNSProvider
|
||||||
deployer port.Deployer
|
deployer port.Deployer
|
||||||
ciProvider port.CIProvider
|
ciProvider port.CIProvider
|
||||||
templateProvider port.TemplateProvider
|
templateProvider port.TemplateProvider
|
||||||
domainRepo port.ProjectDomainRepository
|
domainRepo port.ProjectDomainRepository
|
||||||
slugGenerator port.SlugGenerator
|
slugGenerator port.SlugGenerator
|
||||||
credentialStore port.CredentialStore
|
credentialStore port.CredentialStore
|
||||||
dbProvisioner port.DatabaseProvisioner
|
dbProvisioner port.DatabaseProvisioner
|
||||||
cacheProvisioner port.CacheProvisioner
|
cacheProvisioner port.CacheProvisioner
|
||||||
registryProvider port.RegistryProvider
|
storageProvisioner port.StorageProvisioner
|
||||||
|
registryProvider port.RegistryProvider
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
defaultGitOwner string
|
defaultGitOwner string
|
||||||
@ -101,6 +102,12 @@ func (s *ProjectInfraService) WithCacheProvisioner(cp port.CacheProvisioner) *Pr
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithStorageProvisioner sets the storage provisioner for project storage (GCS).
|
||||||
|
func (s *ProjectInfraService) WithStorageProvisioner(sp port.StorageProvisioner) *ProjectInfraService {
|
||||||
|
s.storageProvisioner = sp
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// WithRegistryProvider sets the container registry provider for image cleanup.
|
// WithRegistryProvider sets the container registry provider for image cleanup.
|
||||||
func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService {
|
func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService {
|
||||||
s.registryProvider = rp
|
s.registryProvider = rp
|
||||||
|
|||||||
@ -712,27 +712,34 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Delete container images from registry
|
// 4. Delete provisioned storage (GCS bucket)
|
||||||
|
if s.storageProvisioner != nil {
|
||||||
|
if err := s.storageProvisioner.DeleteProjectBucket(ctx, projectID, true); err != nil {
|
||||||
|
log.Warn("failed to delete project storage", logging.FieldError, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Delete container images from registry
|
||||||
if s.registryProvider != nil {
|
if s.registryProvider != nil {
|
||||||
if err := s.registryProvider.DeleteProjectRepositories(ctx, projectID); err != nil {
|
if err := s.registryProvider.DeleteProjectRepositories(ctx, projectID); err != nil {
|
||||||
log.Warn("failed to delete project registry images", logging.FieldError, err)
|
log.Warn("failed to delete project registry images", logging.FieldError, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Delete all DNS records for project domains
|
// 7. Delete all DNS records for project domains
|
||||||
s.deleteDNSRecords(ctx, status)
|
s.deleteDNSRecords(ctx, status)
|
||||||
|
|
||||||
// 6. Delete all project_domains entries (CASCADE should handle this, but be explicit)
|
// 8. Delete all project_domains entries (CASCADE should handle this, but be explicit)
|
||||||
if s.domainRepo != nil {
|
if s.domainRepo != nil {
|
||||||
if err := s.domainRepo.DeleteByProject(ctx, projectID); err != nil {
|
if err := s.domainRepo.DeleteByProject(ctx, projectID); err != nil {
|
||||||
log.Warn("failed to delete project domains", logging.FieldError, err)
|
log.Warn("failed to delete project domains", logging.FieldError, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Delete git repo (optional - might want to keep it)
|
// 9. Delete git repo (optional - might want to keep it)
|
||||||
// Skipping git repo deletion for safety
|
// Skipping git repo deletion for safety
|
||||||
|
|
||||||
// 8. Delete from database
|
// 10. Delete from database
|
||||||
_, err = s.db.ExecContext(ctx, `DELETE FROM projects WHERE id = $1`, projectID)
|
_, err = s.db.ExecContext(ctx, `DELETE FROM projects WHERE id = $1`, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete project from database: %w", err)
|
return fmt.Errorf("failed to delete project from database: %w", err)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user