Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Replace per-project notify host provisioning (7-9 API calls + DNS + async Resend verification) with a shared platform host for all *.threesix.ai projects. Under the new model: - CreateProjectNotify: 3 calls only (account + send key + host grant) - No per-project Resend domain, DNS records, or async verification - All *.threesix.ai projects share `threesix.ai` as the platform host - Custom domains still get a dedicated host via ReprovisionNotifyHost Changes: - domain/notify.go: slim NotifyCredentials (no Host/From/ResendDomainID); add NotifyHostCredentials for reprovision return path - port/notify_provisioner.go: update interface signatures and docs - adapter/notify/provisioner.go: rewrite CreateProjectNotify (3 steps); rewrite DeleteProjectNotify (account-only vs full cleanup) - adapter/notify/provisioner_reprovision.go: return *NotifyHostCredentials - adapter/notify/provisioner_test.go: update tests for new model - service/project_infra_crud.go: store only NOTIFY_API_KEY on provision - domain/credential.go: add CredKeyNotifySharedHost/CredKeyNotifySharedFrom - cmd/rdev-api/config.go: add NotifySharedHost/NotifySharedFrom to InfraConfig - service/component.go: add notifySharedHost/notifySharedFrom + WithNotifyDefaults - service/component_deploy.go: inject shared host defaults when no custom host stored - handlers/notify.go: handle shared-host projects in Reprovision guard; add WithSharedNotifyHost builder - cmd/rdev-api/main.go: wire SharedHost to provisioner, component service, and notify handler Bootstrap: NOTIFY_SHARED_HOST=threesix.ai and NOTIFY_SHARED_FROM=noreply@threesix.ai stored in credential store (host id=1 already provisioned with Resend provider). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
10 KiB
Go
255 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/envutil"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
"github.com/orchard9/rdev/internal/worker"
|
|
)
|
|
|
|
// Config holds application configuration.
|
|
type Config struct {
|
|
Port int
|
|
DBHost string
|
|
DBPort int
|
|
DBUser string
|
|
DBPassword string
|
|
DBName string
|
|
DBSSLMode string
|
|
AdminKey string
|
|
|
|
// Credential store encryption key (required for storing secrets in DB)
|
|
CredentialEncryptionKey string
|
|
|
|
// OpenCode configuration (optional - enables OpenCode as alternative code agent)
|
|
OpenCodeURL string // e.g., "http://opencode:4096"
|
|
OpenCodeUsername string // Basic auth username (default: "opencode")
|
|
OpenCodePassword string // Basic auth password
|
|
|
|
// Internal API token for service-to-service callbacks
|
|
InternalToken string
|
|
|
|
// Citadel logging integration
|
|
CitadelURL string // e.g., "https://citadel-staging.orchard9.ai"
|
|
CitadelAPIKey string // API key for Citadel (starts with ck_live_ or ck_dev_)
|
|
CitadelPlatformTenantID string // Tenant ID for the rdev-platform environment
|
|
|
|
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
|
|
GiteaURL string
|
|
GiteaToken string
|
|
GiteaDefaultOrg string
|
|
CloudflareToken string
|
|
CloudflareZoneID string
|
|
DefaultDomain string
|
|
DeployNamespace string
|
|
DeployTLSIssuer string
|
|
ClusterIP string
|
|
RegistryURL string
|
|
WoodpeckerURL string
|
|
WoodpeckerAPIToken string
|
|
WoodpeckerWebhookSecret string
|
|
}
|
|
|
|
// InfraConfig holds infrastructure adapter configuration.
|
|
// Loaded from credential store with env var fallback.
|
|
type InfraConfig struct {
|
|
GiteaURL string
|
|
GiteaToken string
|
|
GiteaDefaultOrg string
|
|
CloudflareToken string
|
|
CloudflareZoneID string
|
|
DefaultDomain string
|
|
DeployNamespace string
|
|
DeployTLSIssuer string
|
|
ClusterIP string
|
|
RegistryURL string
|
|
WoodpeckerURL string
|
|
WoodpeckerAPIToken string
|
|
WoodpeckerWebhookSecret string
|
|
|
|
// CockroachDB provisioner (for project databases)
|
|
CRDBHost string // e.g., "cockroachdb-public.databases.svc.cluster.local"
|
|
CRDBPort int // e.g., 26257
|
|
CRDBUser string // e.g., "root" (insecure mode)
|
|
CRDBSSLMode string // e.g., "disable" (insecure) or "verify-full" (production)
|
|
|
|
// Redis provisioner (for project cache)
|
|
RedisHost string // e.g., "redis.databases.svc.cluster.local"
|
|
RedisPort int // e.g., 6379
|
|
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")
|
|
|
|
// Notify provisioner (for per-project email delivery)
|
|
NotifyURL string // e.g., "https://notify.orchard9.ai"
|
|
NotifyAdminKey string // notify_admin_... admin API key
|
|
ResendAPIKey string // re_... Resend API key for per-project domain provisioning
|
|
NotifySharedHost string // pre-provisioned platform sending host (e.g., "mail.threesix.ai")
|
|
NotifySharedFrom string // from-address for the shared host (e.g., "noreply@mail.threesix.ai")
|
|
}
|
|
|
|
func loadConfig() Config {
|
|
return Config{
|
|
Port: envutil.GetEnvInt("PORT", 8080),
|
|
DBHost: envutil.GetEnv("DB_HOST", "postgres.databases.svc.cluster.local"),
|
|
DBPort: envutil.GetEnvInt("DB_PORT", 5432),
|
|
DBUser: envutil.GetEnv("DB_USER", "appuser"),
|
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
|
DBName: envutil.GetEnv("DB_NAME", "rdev"),
|
|
DBSSLMode: envutil.GetEnv("DB_SSL_MODE", "disable"),
|
|
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
|
|
|
// Encryption key for credential store (generate with: openssl rand -base64 32)
|
|
// REQUIRED in production - no default to prevent insecure deployments
|
|
CredentialEncryptionKey: os.Getenv("CREDENTIAL_ENCRYPTION_KEY"),
|
|
|
|
// OpenCode (optional alternative code agent)
|
|
OpenCodeURL: os.Getenv("OPENCODE_URL"), // e.g., "http://opencode:4096"
|
|
OpenCodeUsername: envutil.GetEnv("OPENCODE_USERNAME", "opencode"),
|
|
OpenCodePassword: os.Getenv("OPENCODE_PASSWORD"),
|
|
|
|
// Internal API token for service-to-service callbacks (e.g., SDLC callbacks)
|
|
InternalToken: os.Getenv("INTERNAL_TOKEN"),
|
|
|
|
// Citadel logging integration
|
|
CitadelURL: os.Getenv("CITADEL_URL"), // e.g., "https://citadel-staging.orchard9.ai"
|
|
CitadelAPIKey: os.Getenv("CITADEL_API_KEY"), // API key for Citadel
|
|
CitadelPlatformTenantID: os.Getenv("CITADEL_PLATFORM_TENANT_ID"), // rdev-platform tenant ID
|
|
|
|
// Infrastructure adapters (fallback if not in credential store)
|
|
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
|
|
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
|
GiteaDefaultOrg: envutil.GetEnv("GITEA_DEFAULT_ORG", "threesix"),
|
|
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
|
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
|
DefaultDomain: envutil.GetEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
|
DeployNamespace: envutil.GetEnv("DEPLOY_NAMESPACE", "projects"),
|
|
DeployTLSIssuer: envutil.GetEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
|
|
ClusterIP: envutil.GetEnv("CLUSTER_IP", "208.122.204.172"),
|
|
RegistryURL: envutil.GetEnv("REGISTRY_URL", "registry.threesix.ai"),
|
|
WoodpeckerURL: envutil.GetEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
|
|
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
|
|
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
|
|
}
|
|
}
|
|
|
|
// loadInfraConfig loads infrastructure configuration from credential store,
|
|
// falling back to environment variables if not found in the store.
|
|
func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig {
|
|
// Try to load from credential store
|
|
creds, err := store.GetMultiple(ctx, []string{
|
|
domain.CredKeyGiteaToken,
|
|
domain.CredKeyGiteaURL,
|
|
domain.CredKeyCloudflareAPIToken,
|
|
domain.CredKeyCloudflareZoneID,
|
|
domain.CredKeyWoodpeckerURL,
|
|
domain.CredKeyWoodpeckerAPIToken,
|
|
domain.CredKeyWoodpeckerWebhookSecret,
|
|
domain.CredKeyRegistryURL,
|
|
domain.CredKeyNotifyURL,
|
|
domain.CredKeyNotifyAdminKey,
|
|
domain.CredKeyResendAPIKey,
|
|
domain.CredKeyNotifySharedHost,
|
|
domain.CredKeyNotifySharedFrom,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("failed to load credentials from store, using env vars", "error", err)
|
|
creds = make(map[string]string)
|
|
}
|
|
|
|
// Helper to get from store or fall back to env var
|
|
getOrFallback := func(key, envFallback string) string {
|
|
if v, ok := creds[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
return envFallback
|
|
}
|
|
|
|
infraCfg := InfraConfig{
|
|
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
|
|
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
|
|
GiteaDefaultOrg: cfg.GiteaDefaultOrg, // Not a secret, use env
|
|
CloudflareToken: getOrFallback(domain.CredKeyCloudflareAPIToken, cfg.CloudflareToken),
|
|
CloudflareZoneID: getOrFallback(domain.CredKeyCloudflareZoneID, cfg.CloudflareZoneID),
|
|
DefaultDomain: cfg.DefaultDomain, // Not a secret, use env
|
|
DeployNamespace: cfg.DeployNamespace, // Not a secret, use env
|
|
DeployTLSIssuer: cfg.DeployTLSIssuer, // Not a secret, use env
|
|
ClusterIP: cfg.ClusterIP, // Not a secret, use env
|
|
RegistryURL: getOrFallback(domain.CredKeyRegistryURL, cfg.RegistryURL),
|
|
WoodpeckerURL: getOrFallback(domain.CredKeyWoodpeckerURL, cfg.WoodpeckerURL),
|
|
WoodpeckerAPIToken: getOrFallback(domain.CredKeyWoodpeckerAPIToken, cfg.WoodpeckerAPIToken),
|
|
WoodpeckerWebhookSecret: getOrFallback(domain.CredKeyWoodpeckerWebhookSecret, cfg.WoodpeckerWebhookSecret),
|
|
|
|
// CockroachDB and Redis provisioners (env-only for now)
|
|
CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc.cluster.local"
|
|
CRDBPort: envutil.GetEnvInt("CRDB_PORT", 26257),
|
|
CRDBUser: envutil.GetEnv("CRDB_USER", "root"),
|
|
CRDBSSLMode: envutil.GetEnv("CRDB_SSL_MODE", "disable"),
|
|
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.databases.svc.cluster.local"
|
|
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
|
|
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"),
|
|
|
|
// Notify provisioner (credential store with env fallback)
|
|
NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")),
|
|
NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")),
|
|
ResendAPIKey: getOrFallback(domain.CredKeyResendAPIKey, os.Getenv("RESEND_API_KEY")),
|
|
NotifySharedHost: getOrFallback(domain.CredKeyNotifySharedHost, os.Getenv("NOTIFY_SHARED_HOST")),
|
|
NotifySharedFrom: getOrFallback(domain.CredKeyNotifySharedFrom, os.Getenv("NOTIFY_SHARED_FROM")),
|
|
}
|
|
|
|
// Log which credentials were loaded from store vs env
|
|
fromStore := 0
|
|
for k := range creds {
|
|
if creds[k] != "" {
|
|
fromStore++
|
|
}
|
|
}
|
|
if fromStore > 0 {
|
|
logger.Info("loaded credentials from store", "count", fromStore)
|
|
}
|
|
|
|
return infraCfg
|
|
}
|
|
|
|
// closeProvisioner attempts to close a provisioner that implements io.Closer.
|
|
func closeProvisioner(p any, name string, logger *slog.Logger) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
if closer, ok := p.(interface{ Close() error }); ok {
|
|
if err := closer.Close(); err != nil {
|
|
logger.Warn("failed to close "+name+" provisioner", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// podGitCommitterAdapter wraps worker.PodGitOperations to satisfy
|
|
// service.PodGitCommitter without creating an import cycle.
|
|
type podGitCommitterAdapter struct {
|
|
podGitOps *worker.PodGitOperations
|
|
}
|
|
|
|
func (a *podGitCommitterAdapter) CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *service.GitCommitResult {
|
|
result := a.podGitOps.CommitAndPush(ctx, podName, workDir, message, push)
|
|
return &service.GitCommitResult{
|
|
HasChanges: result.HasChanges,
|
|
CommitSHA: result.CommitSHA,
|
|
FilesChanged: result.FilesChanged,
|
|
Pushed: result.Pushed,
|
|
Error: result.Error,
|
|
}
|
|
}
|