From 834f98a1162bede37dd36217c281f69801cb9483 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 3 Jun 2026 16:38:52 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(deploy):=20P3=20=E2=80=94=20source=3Dg?= =?UTF-8?q?it=20(pull-by-URL=20build),=20flag-gated=20OFF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /deploy/new with source=git + git_url (+ optional git_ref, git_token) points Kaniko at the repo via its native git context — no tarball upload, so projects over the 10 MB cap can ship from a repo URL. Gated by DEPLOY_SOURCE_GIT_ENABLED (default false) → source=git returns 501 until an operator canary; tarball/image deploys unaffected. - config: DeploySourceGitEnabled flag (+ true/off/default tests, allKeys). - migration 065: deployments.git_url/git_ref/git_token_enc (additive; the 064 source CHECK already permits 'git'). Mirrored in the testhelpers DDL + all three hardcoded deployment-row mock column lists. - model: GitURL/GitRef/GitTokenEnc on Deployment + CreateDeploymentParams + deploymentColumns + scan + INSERT. - handler: source=git case (flag-gate 501 source_git_disabled, validateGitURL, git_ref, git_token encrypt); deploymentToMap echoes git_url/git_ref + git_token_set (token never echoed); applyGitSourceOpts in runDeploy. encryptRegistryCreds generalised to encryptDeploySecret (shared by both). agent_action entries for source_git_disabled + invalid_git_url. - compute: createKanikoJob gains a git-context mode (no build-context volume, GIT_USERNAME/GIT_PASSWORD from a short-lived git-auth Secret); buildImageFromGit mirrors buildImage's namespace/NP/registry-auth prep; Deploy gains a git branch (source switch). DeployOptions += GitURL/GitRef/GitAuth. SSRF hardening (security review): validateGitURL now rejects a git_url whose host is — or resolves to — loopback / RFC1918 / link-local (incl. the 169.254.169.254 metadata endpoint) / unspecified (DNS injectable for tests, fail-closed on resolution failure). Defense-in-depth: the build-pod egress NetworkPolicy now excepts RFC1918 + loopback (metadata/link-local already blocked), so a DNS-rebind or future validator bypass still can't reach internal services. http(s)-only, no embedded credentials. Coverage: config/model/handler/k8s git paths + all error arms covered (fake-clientset reactors for the compute branches; injectable DNS for the SSRF screen). Contract sync (openapi/llms/MCP) deferred to flag-on, as with P2. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/config/config.go | 176 +++++----- internal/config/config_test.go | 20 +- .../db/migrations/065_deploy_source_git.sql | 26 ++ internal/handlers/deploy.go | 180 +++++++++- internal/handlers/deploy_image_source_test.go | 145 +++++++- .../deploy_redeploy_inplace_mock_test.go | 4 + .../deploy_source_git_integration_test.go | 180 ++++++++++ internal/handlers/helpers.go | 8 +- .../models/coverage_provision_gate_test.go | 2 + internal/models/deployment.go | 22 +- internal/providers/compute/k8s/client.go | 189 ++++++++++- internal/providers/compute/k8s/client_test.go | 8 +- .../compute/k8s/coverage_more_test.go | 2 +- .../providers/compute/k8s/deploy_git_test.go | 311 ++++++++++++++++++ internal/providers/compute/provider.go | 8 + internal/testhelpers/testhelpers.go | 4 + 16 files changed, 1165 insertions(+), 120 deletions(-) create mode 100644 internal/db/migrations/065_deploy_source_git.sql create mode 100644 internal/handlers/deploy_source_git_integration_test.go create mode 100644 internal/providers/compute/k8s/deploy_git_test.go diff --git a/internal/config/config.go b/internal/config/config.go index b6d3976a..90fd9d49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,32 +10,32 @@ import ( // Config holds all runtime configuration for the platform. type Config struct { - Port string - DatabaseURL string // platform DB: teams, users, resources - CustomerDatabaseURL string // customer DB: provisioned db_{token} databases (Phase 2+) - RedisURL string - JWTSecret string - AESKey string - MaxMindLicenseKey string - GeoLite2DBPath string - RazorpayKeyID string // RAZORPAY_KEY_ID — API key ID (used server-side) - RazorpayKeySecret string // RAZORPAY_KEY_SECRET — API key secret - RazorpayWebhookSecret string // RAZORPAY_WEBHOOK_SECRET — webhook signature verification - RazorpayPlanIDHobby string // RAZORPAY_PLAN_ID_HOBBY — plan_id for hobby tier (monthly) + Port string + DatabaseURL string // platform DB: teams, users, resources + CustomerDatabaseURL string // customer DB: provisioned db_{token} databases (Phase 2+) + RedisURL string + JWTSecret string + AESKey string + MaxMindLicenseKey string + GeoLite2DBPath string + RazorpayKeyID string // RAZORPAY_KEY_ID — API key ID (used server-side) + RazorpayKeySecret string // RAZORPAY_KEY_SECRET — API key secret + RazorpayWebhookSecret string // RAZORPAY_WEBHOOK_SECRET — webhook signature verification + RazorpayPlanIDHobby string // RAZORPAY_PLAN_ID_HOBBY — plan_id for hobby tier (monthly) // RazorpayPlanIDHobbyPlus — plan_id for the W11 hobby_plus tier // ($19/mo, monthly). When unset, /api/v1/billing/checkout with // plan="hobby_plus" returns 503 billing_not_configured. The operator // must create the corresponding Razorpay subscription plan in the // dashboard and set this env var before checkout will work. - RazorpayPlanIDHobbyPlus string // RAZORPAY_PLAN_ID_HOBBY_PLUS — plan_id for hobby_plus tier (monthly) - RazorpayPlanIDPro string // RAZORPAY_PLAN_ID_PRO — plan_id for pro tier (monthly) + RazorpayPlanIDHobbyPlus string // RAZORPAY_PLAN_ID_HOBBY_PLUS — plan_id for hobby_plus tier (monthly) + RazorpayPlanIDPro string // RAZORPAY_PLAN_ID_PRO — plan_id for pro tier (monthly) // RazorpayPlanIDGrowth — plan_id for the W12 growth tier ($99/mo, // monthly). When unset, /api/v1/billing/checkout with plan="growth" // returns 503 billing_not_configured; the reconciler also logs // `billing.plan_id_to_tier.unrecognised` for any incoming Growth // webhook so the operator notices the gap. D28 F3 (2026-05-21). - RazorpayPlanIDGrowth string // RAZORPAY_PLAN_ID_GROWTH — plan_id for growth tier (monthly) - RazorpayPlanIDTeam string // RAZORPAY_PLAN_ID_TEAM — plan_id for team tier (monthly) + RazorpayPlanIDGrowth string // RAZORPAY_PLAN_ID_GROWTH — plan_id for growth tier (monthly) + RazorpayPlanIDTeam string // RAZORPAY_PLAN_ID_TEAM — plan_id for team tier (monthly) // Yearly billing variants. When unset, the corresponding yearly checkout // returns 503 billing_not_configured so partial rollout (monthly already // live, yearly plans not yet created in Razorpay dashboard) is safe. @@ -44,23 +44,23 @@ type Config struct { RazorpayPlanIDProYearly string // RAZORPAY_PLAN_ID_PRO_YEARLY — plan_id for pro tier (yearly) RazorpayPlanIDGrowthYearly string // RAZORPAY_PLAN_ID_GROWTH_ANNUAL — plan_id for growth tier (yearly) RazorpayPlanIDTeamYearly string // RAZORPAY_PLAN_ID_TEAM_YEARLY — plan_id for team tier (yearly) - ResendAPIKey string + ResendAPIKey string // EmailProvider explicitly selects the outbound email backend. Accepted // values: "brevo" | "resend" | "noop". When empty, internal/email // auto-detects: BREVO_API_KEY > RESEND_API_KEY (≠ "CHANGE_ME") > noop. // Added 2026-05-14 to recover from the live RESEND_API_KEY="CHANGE_ME" // outage by routing through the already-provisioned BREVO_API_KEY. - EmailProvider string - BrevoAPIKey string // BREVO_API_KEY — Brevo Transactional Email API key - EmailFromName string // EMAIL_FROM_NAME — verified-sender display name (default "InstaNode") - EmailFromAddress string // EMAIL_FROM_ADDRESS — verified-sender email (default "noreply@instanode.dev") - GitHubClientID string - GitHubClientSecret string - GoogleClientID string - GoogleClientSecret string - GoogleRedirectURI string // optional default redirect_uri for GET /auth/google/url - EnabledServices string - Environment string + EmailProvider string + BrevoAPIKey string // BREVO_API_KEY — Brevo Transactional Email API key + EmailFromName string // EMAIL_FROM_NAME — verified-sender display name (default "InstaNode") + EmailFromAddress string // EMAIL_FROM_ADDRESS — verified-sender email (default "noreply@instanode.dev") + GitHubClientID string + GitHubClientSecret string + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURI string // optional default redirect_uri for GET /auth/google/url + EnabledServices string + Environment string // TrustedProxyCIDRs is the comma-separated list of CIDR ranges that the // API will trust the X-Forwarded-For header from. Set this to the // load-balancer egress CIDRs (e.g. DOKS NodePool subnet) so that XFF is @@ -102,9 +102,9 @@ type Config struct { NATSOperatorSeed string // NATS_OPERATOR_SEED — operator NKey seed; empty = legacy_open fallback NATSSystemAccountKey string // NATS_SYSTEM_ACCOUNT_PUBLIC_KEY — system account public key NATSUseTLS bool // NATS_USE_TLS — true → tls:// URLs - R2Endpoint string // R2_ENDPOINT — R2 endpoint hostname (default: r2.instant.dev) - R2BucketName string // R2_BUCKET_NAME — shared R2 bucket name (default: instant-shared) - R2APIToken string // R2_API_TOKEN — Cloudflare API token; if empty, R2 is not used + R2Endpoint string // R2_ENDPOINT — R2 endpoint hostname (default: r2.instant.dev) + R2BucketName string // R2_BUCKET_NAME — shared R2 bucket name (default: instant-shared) + R2APIToken string // R2_API_TOKEN — Cloudflare API token; if empty, R2 is not used // Object storage backend for /storage/new (provider-agnostic). // // ObjectStoreBackend selects the credential-issuance strategy: @@ -115,15 +115,15 @@ type Config struct { // prefix to every customer (trust-based isolation). // Defaults to "minio-admin" when ObjectStoreBackend is empty AND the // legacy MINIO_* env vars are set; otherwise "shared-key". - ObjectStoreMode string // OBJECT_STORE_MODE — "admin" (default) or "shared_key"; alias of ObjectStoreBackend - ObjectStoreBackend string // OBJECT_STORE_BACKEND — "minio-admin" or "shared-key" (legacy alias of OBJECT_STORE_MODE) - ObjectStoreEndpoint string // OBJECT_STORE_ENDPOINT — host:port for admin/bucket ops - ObjectStorePublicURL string // OBJECT_STORE_PUBLIC_URL — customer-facing base, e.g. "https://s3.instanode.dev" - ObjectStoreAccessKey string // OBJECT_STORE_ACCESS_KEY — master access key - ObjectStoreSecretKey string // OBJECT_STORE_SECRET_KEY — master secret key - ObjectStoreBucket string // OBJECT_STORE_BUCKET — shared bucket (default: instant-shared) - ObjectStoreRegion string // OBJECT_STORE_REGION — e.g. "nyc3" for DO Spaces, "us-east-1" for AWS S3 - ObjectStoreSecure bool // OBJECT_STORE_SECURE — true for TLS-terminated endpoints (DO Spaces, AWS S3); default false for in-cluster MinIO + ObjectStoreMode string // OBJECT_STORE_MODE — "admin" (default) or "shared_key"; alias of ObjectStoreBackend + ObjectStoreBackend string // OBJECT_STORE_BACKEND — "minio-admin" or "shared-key" (legacy alias of OBJECT_STORE_MODE) + ObjectStoreEndpoint string // OBJECT_STORE_ENDPOINT — host:port for admin/bucket ops + ObjectStorePublicURL string // OBJECT_STORE_PUBLIC_URL — customer-facing base, e.g. "https://s3.instanode.dev" + ObjectStoreAccessKey string // OBJECT_STORE_ACCESS_KEY — master access key + ObjectStoreSecretKey string // OBJECT_STORE_SECRET_KEY — master secret key + ObjectStoreBucket string // OBJECT_STORE_BUCKET — shared bucket (default: instant-shared) + ObjectStoreRegion string // OBJECT_STORE_REGION — e.g. "nyc3" for DO Spaces, "us-east-1" for AWS S3 + ObjectStoreSecure bool // OBJECT_STORE_SECURE — true for TLS-terminated endpoints (DO Spaces, AWS S3); default false for in-cluster MinIO // ObjectStoreAllowSharedKey is the explicit operator escape hatch that // permits shared-key mode in production. Without this flag, the router @@ -141,7 +141,7 @@ type Config struct { MinioRootUser string // MINIO_ROOT_USER — legacy alias for OBJECT_STORE_ACCESS_KEY MinioRootPassword string // MINIO_ROOT_PASSWORD — legacy alias for OBJECT_STORE_SECRET_KEY MinioBucketName string // MINIO_BUCKET_NAME — legacy alias for OBJECT_STORE_BUCKET - DeployDomain string // DEPLOY_DOMAIN — base domain for container deployments (default: instant.dev) + DeployDomain string // DEPLOY_DOMAIN — base domain for container deployments (default: instant.dev) // Compute provider for app hosting (Phase 6) ComputeProvider string // COMPUTE_PROVIDER — "noop" or "k8s" (default: "noop") @@ -182,15 +182,21 @@ type Config struct { // rejects source=image with 501; tarball deploys are unaffected. DeploySourceImageEnabled bool + // DeploySourceGitEnabled gates the source=git deploy path (P3): the + // platform points Kaniko at a git repo URL (shallow clone, optional + // encrypted token for private repos) instead of an uploaded tarball. + // Off → /deploy/new rejects source=git with 501; tarball/image unaffected. + DeploySourceGitEnabled bool + // Email-feedback webhook secrets. Each provider authenticates its // callbacks differently — these env vars give the handler the shared // secret (Brevo, SendGrid) or topic ARN (SES via SNS) it needs to // reject unsigned traffic. All three may be empty in local dev; the // handlers then 401 every request, which is the correct fail-closed // behavior for an unauthenticated public endpoint. - BrevoWebhookSecret string // BREVO_WEBHOOK_SECRET — shared secret for HMAC-SHA256 verification - SESSNSTopicARN string // SES_SNS_SUBSCRIPTION_ARN — expected SNS TopicArn on inbound notifications - SendGridWebhookKey string // SENDGRID_WEBHOOK_PUBLIC_KEY — ECDSA public key (reserved; SendGrid is stubbed today) + BrevoWebhookSecret string // BREVO_WEBHOOK_SECRET — shared secret for HMAC-SHA256 verification + SESSNSTopicARN string // SES_SNS_SUBSCRIPTION_ARN — expected SNS TopicArn on inbound notifications + SendGridWebhookKey string // SENDGRID_WEBHOOK_PUBLIC_KEY — ECDSA public key (reserved; SendGrid is stubbed today) // AdminPathPrefix is the unguessable URL segment under which the // founder-only customer-management endpoints register. When set, @@ -263,25 +269,25 @@ func require(key string) string { // Load reads configuration from environment variables. Panics on missing required fields. func Load() *Config { cfg := &Config{ - Port: getenv("PORT", "8080"), - DatabaseURL: require("DATABASE_URL"), - CustomerDatabaseURL: getenv("CUSTOMER_DATABASE_URL", ""), - RedisURL: getenv("REDIS_URL", "redis://localhost:6379"), - JWTSecret: strings.TrimSpace(require("JWT_SECRET")), - AESKey: strings.TrimSpace(require("AES_KEY")), - MaxMindLicenseKey: os.Getenv("MAXMIND_LICENSE_KEY"), - GeoLite2DBPath: getenv("GEOLITE2_DB_PATH", "./GeoLite2-City.mmdb"), - RazorpayKeyID: os.Getenv("RAZORPAY_KEY_ID"), - RazorpayKeySecret: os.Getenv("RAZORPAY_KEY_SECRET"), - RazorpayWebhookSecret: os.Getenv("RAZORPAY_WEBHOOK_SECRET"), - RazorpayPlanIDHobby: os.Getenv("RAZORPAY_PLAN_ID_HOBBY"), - RazorpayPlanIDHobbyPlus: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_PLUS"), - RazorpayPlanIDPro: os.Getenv("RAZORPAY_PLAN_ID_PRO"), + Port: getenv("PORT", "8080"), + DatabaseURL: require("DATABASE_URL"), + CustomerDatabaseURL: getenv("CUSTOMER_DATABASE_URL", ""), + RedisURL: getenv("REDIS_URL", "redis://localhost:6379"), + JWTSecret: strings.TrimSpace(require("JWT_SECRET")), + AESKey: strings.TrimSpace(require("AES_KEY")), + MaxMindLicenseKey: os.Getenv("MAXMIND_LICENSE_KEY"), + GeoLite2DBPath: getenv("GEOLITE2_DB_PATH", "./GeoLite2-City.mmdb"), + RazorpayKeyID: os.Getenv("RAZORPAY_KEY_ID"), + RazorpayKeySecret: os.Getenv("RAZORPAY_KEY_SECRET"), + RazorpayWebhookSecret: os.Getenv("RAZORPAY_WEBHOOK_SECRET"), + RazorpayPlanIDHobby: os.Getenv("RAZORPAY_PLAN_ID_HOBBY"), + RazorpayPlanIDHobbyPlus: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_PLUS"), + RazorpayPlanIDPro: os.Getenv("RAZORPAY_PLAN_ID_PRO"), // D28 F3 (2026-05-21): Growth tier — was previously missing from // the env-mapping, causing every subscription.charged webhook for // a Growth customer to fall back to "hobby" and silently downgrade. - RazorpayPlanIDGrowth: os.Getenv("RAZORPAY_PLAN_ID_GROWTH"), - RazorpayPlanIDTeam: os.Getenv("RAZORPAY_PLAN_ID_TEAM"), + RazorpayPlanIDGrowth: os.Getenv("RAZORPAY_PLAN_ID_GROWTH"), + RazorpayPlanIDTeam: os.Getenv("RAZORPAY_PLAN_ID_TEAM"), // 2026-05-15: the live instant-secrets uses the `_ANNUAL` suffix // for every yearly plan id. config.go previously read `_YEARLY` // for Hobby + Pro (only HobbyPlus read `_ANNUAL`), so os.Getenv @@ -296,27 +302,27 @@ func Load() *Config { RazorpayPlanIDProYearly: os.Getenv("RAZORPAY_PLAN_ID_PRO_ANNUAL"), RazorpayPlanIDGrowthYearly: os.Getenv("RAZORPAY_PLAN_ID_GROWTH_ANNUAL"), RazorpayPlanIDTeamYearly: os.Getenv("RAZORPAY_PLAN_ID_TEAM_ANNUAL"), - ResendAPIKey: os.Getenv("RESEND_API_KEY"), - EmailProvider: os.Getenv("EMAIL_PROVIDER"), - BrevoAPIKey: os.Getenv("BREVO_API_KEY"), - EmailFromName: os.Getenv("EMAIL_FROM_NAME"), - EmailFromAddress: os.Getenv("EMAIL_FROM_ADDRESS"), - GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), - GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), - GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), - GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), - GoogleRedirectURI: os.Getenv("GOOGLE_REDIRECT_URI"), - EnabledServices: getenv("INSTANT_ENABLED_SERVICES", "redis,postgres,mongodb,queue"), - Environment: getenv("ENVIRONMENT", "development"), - TrustedProxyCIDRs: os.Getenv("TRUSTED_PROXY_CIDRS"), - RedisProvisionBackend: getenv("REDIS_PROVISION_BACKEND", "local"), - RedisProvisionHost: getenv("REDIS_PROVISION_HOST", "localhost"), - MongoAdminURI: getenv("MONGO_ADMIN_URI", "mongodb://root:root@localhost:27017"), - MongoHost: getenv("MONGO_HOST", "localhost:27017"), - PostgresProvisionBackend: getenv("POSTGRES_PROVISION_BACKEND", "local"), - NeonAPIKey: os.Getenv("NEON_API_KEY"), - NeonRegionID: getenv("NEON_REGION_ID", "aws-us-east-1"), - PostgresCustomersURL: getenv("POSTGRES_CUSTOMERS_URL", "postgres://postgres:postgres@postgres-customers:5432/postgres"), + ResendAPIKey: os.Getenv("RESEND_API_KEY"), + EmailProvider: os.Getenv("EMAIL_PROVIDER"), + BrevoAPIKey: os.Getenv("BREVO_API_KEY"), + EmailFromName: os.Getenv("EMAIL_FROM_NAME"), + EmailFromAddress: os.Getenv("EMAIL_FROM_ADDRESS"), + GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), + GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + GoogleRedirectURI: os.Getenv("GOOGLE_REDIRECT_URI"), + EnabledServices: getenv("INSTANT_ENABLED_SERVICES", "redis,postgres,mongodb,queue"), + Environment: getenv("ENVIRONMENT", "development"), + TrustedProxyCIDRs: os.Getenv("TRUSTED_PROXY_CIDRS"), + RedisProvisionBackend: getenv("REDIS_PROVISION_BACKEND", "local"), + RedisProvisionHost: getenv("REDIS_PROVISION_HOST", "localhost"), + MongoAdminURI: getenv("MONGO_ADMIN_URI", "mongodb://root:root@localhost:27017"), + MongoHost: getenv("MONGO_HOST", "localhost:27017"), + PostgresProvisionBackend: getenv("POSTGRES_PROVISION_BACKEND", "local"), + NeonAPIKey: os.Getenv("NEON_API_KEY"), + NeonRegionID: getenv("NEON_REGION_ID", "aws-us-east-1"), + PostgresCustomersURL: getenv("POSTGRES_CUSTOMERS_URL", "postgres://postgres:postgres@postgres-customers:5432/postgres"), } cfg.ProvisionerAddr = os.Getenv("PROVISIONER_ADDR") // intentionally empty = use local providers cfg.ProvisionerSecret = os.Getenv("PROVISIONER_SECRET") @@ -437,6 +443,14 @@ func Load() *Config { cfg.DeploySourceImageEnabled = false } + // DEPLOY_SOURCE_GIT_ENABLED: default FALSE (off until operator canary). + switch strings.ToLower(strings.TrimSpace(os.Getenv("DEPLOY_SOURCE_GIT_ENABLED"))) { + case "true", "1", "yes": + cfg.DeploySourceGitEnabled = true + default: + cfg.DeploySourceGitEnabled = false + } + if len(cfg.JWTSecret) < 32 { panic("JWT_SECRET must be at least 32 bytes") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4228b134..89448308 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -62,7 +62,7 @@ func allKeys() []string { "DEPLOY_DOMAIN", "COMPUTE_PROVIDER", "KUBE_NAMESPACE_APPS", "METRICS_TOKEN", "DASHBOARD_BASE_URL", "API_PUBLIC_URL", "DELETION_CONFIRMATION_TTL_MINUTES", "FAMILY_BINDINGS_ENABLED", - "DEPLOY_SOURCE_IMAGE_ENABLED", + "DEPLOY_SOURCE_IMAGE_ENABLED", "DEPLOY_SOURCE_GIT_ENABLED", "BREVO_WEBHOOK_SECRET", "SES_SNS_SUBSCRIPTION_ARN", "SENDGRID_WEBHOOK_PUBLIC_KEY", "WORKER_INTERNAL_JWT_SECRET", "ADMIN_PATH_PREFIX", @@ -245,6 +245,9 @@ func TestLoad_HappyPath_AppliesDefaults(t *testing.T) { if cfg.DeploySourceImageEnabled { t.Error("DeploySourceImageEnabled default must be false (off until operator canary)") } + if cfg.DeploySourceGitEnabled { + t.Error("DeploySourceGitEnabled default must be false (off until operator canary)") + } // Object store mode resolution: with everything empty → "admin" / "minio-admin" if cfg.ObjectStoreMode != "admin" || cfg.ObjectStoreBackend != "minio-admin" { t.Errorf("ObjectStoreMode/Backend defaults: %q/%q", cfg.ObjectStoreMode, cfg.ObjectStoreBackend) @@ -350,6 +353,21 @@ func TestLoad_DeploySourceImageEnabled(t *testing.T) { } } +func TestLoad_DeploySourceGitEnabled(t *testing.T) { + for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} { + applyBaselineEnv(t, map[string]string{"DEPLOY_SOURCE_GIT_ENABLED": val}) + if !Load().DeploySourceGitEnabled { + t.Errorf("DEPLOY_SOURCE_GIT_ENABLED=%q should enable", val) + } + } + for _, val := range []string{"false", "0", "no", "maybe", ""} { + applyBaselineEnv(t, map[string]string{"DEPLOY_SOURCE_GIT_ENABLED": val}) + if Load().DeploySourceGitEnabled { + t.Errorf("DEPLOY_SOURCE_GIT_ENABLED=%q should stay disabled", val) + } + } +} + func TestLoad_DeletionTTL_OverrideAndInvalid(t *testing.T) { applyBaselineEnv(t, map[string]string{"DELETION_CONFIRMATION_TTL_MINUTES": "30"}) if got := Load().DeletionConfirmationTTLMinutes; got != 30 { diff --git a/internal/db/migrations/065_deploy_source_git.sql b/internal/db/migrations/065_deploy_source_git.sql new file mode 100644 index 00000000..8d55e159 --- /dev/null +++ b/internal/db/migrations/065_deploy_source_git.sql @@ -0,0 +1,26 @@ +-- 065_deploy_source_git.sql — multi-source deploys, P3 (source=git / pull-by-URL). +-- +-- WHY: P2 (#221, mig 064) added source='image' (deploy a prebuilt ref). P3 adds +-- source='git': the caller passes a repo URL (+ optional ref + token) and the +-- platform points Kaniko at the repo directly (git context build), so large +-- projects that exceed the 10 MB tarball cap can ship without an upload or a +-- pre-built image. source='git' is already permitted by the 064 +-- deployments_source_check, so no constraint change is needed here. +-- +-- All columns are ADDITIVE with safe defaults — every existing row + tarball +-- and image deploy keeps working unchanged: +-- +-- git_url — clone URL (https://host/owner/repo[.git]) for source='git'; +-- '' otherwise. +-- git_ref — branch / tag / commit SHA to build; '' = provider default +-- branch. +-- git_token_enc — AES-256-GCM ciphertext of an optional read-only access +-- token for a PRIVATE repo (same whole-object encryption as +-- registry_creds_enc / notify_webhook_secret). '' for public +-- repos. NEVER returned to the client (deploymentToMap emits +-- only git_token_set: bool). + +ALTER TABLE deployments + ADD COLUMN IF NOT EXISTS git_url TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS git_ref TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS git_token_enc TEXT NOT NULL DEFAULT ''; diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 489b5e80..63daff31 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -26,6 +26,8 @@ import ( "io" "log/slog" "mime/multipart" + "net" + "net/url" "strconv" "strings" "time" @@ -126,12 +128,125 @@ func applyImageSourceOpts(opts *compute.DeployOptions, d *models.Deployment, aes opts.RegistryAuth = plain } -// encryptRegistryCreds AES-256-GCM-encrypts a BYO private-registry docker -// config JSON for at-rest storage. ParseAESKey is the only failure mode worth -// a distinct branch (a misconfigured/short AES_KEY); crypto.Encrypt over a -// valid key does not fail for these inputs, so its error is returned verbatim -// without a separate handler branch. Returns the ciphertext for persistence. -func encryptRegistryCreds(aesKeyHex, plaintext string) (string, error) { +// validateGitURL checks a source=git clone URL is a well-formed http(s) URL with +// a host (e.g. https://github.com/owner/repo). We accept only http/https — ssh +// (git@…) and git:// schemes are rejected because the build pod authenticates +// with a token over https, and an arbitrary scheme is an SSRF / scheme-confusion +// risk on shared build infra. Returns the trimmed, validated URL. +func validateGitURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("git_url is required for source=git (e.g. https://github.com/owner/repo)") + } + if len(raw) > 512 { + return "", fmt.Errorf("git_url is too long") + } + for _, r := range raw { + if r <= ' ' || r > '~' { + return "", fmt.Errorf("git_url contains invalid characters") + } + } + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("git_url is not a valid URL") + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("git_url must be an http(s) URL (e.g. https://github.com/owner/repo), not %q", u.Scheme) + } + if u.Host == "" { + return "", fmt.Errorf("git_url must include a host (e.g. https://github.com/owner/repo)") + } + if u.User != nil { + // Reject inline credentials (https://user:pass@host) — pass a token via + // the git_token field so it's encrypted at rest, not embedded in the URL. + return "", fmt.Errorf("git_url must not embed credentials; pass a private-repo token via git_token instead") + } + // SSRF guard: reject a host that IS, or resolves to, an internal address — + // loopback, RFC1918 private, link-local (incl. the 169.254.169.254 cloud + // metadata endpoint), or unspecified. The build pod's egress NetworkPolicy + // is the authoritative runtime control (and blocks DNS-rebinding too); this + // gives a clean 400 for the common direct attempts instead of a build failure. + if err := screenGitHost(u.Hostname()); err != nil { + return "", err + } + return raw, nil +} + +// gitHostLookupIP resolves a git_url host to IP addresses for SSRF screening. +// A package var so tests can stub DNS deterministically. +var gitHostLookupIP = net.LookupIP + +// isBlockedDeployIP reports whether an IP is in a range a deploy must never be +// pointed at: loopback, RFC1918 private, link-local unicast/multicast (incl. +// the 169.254.169.254 cloud metadata endpoint), or the unspecified address. +func isBlockedDeployIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() +} + +// screenGitHost rejects a host that is — or whose DNS resolves to — an internal +// address (SSRF guard). A literal IP is checked directly (no DNS); a hostname is +// resolved and rejected if ANY result is internal. A resolution failure is +// itself rejected (fail-closed) — a host we can't resolve can't be cloned anyway. +func screenGitHost(rawHost string) error { + host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(rawHost)), ".") + if host == "" { + return fmt.Errorf("git_url must include a host (e.g. https://github.com/owner/repo)") + } + if ip := net.ParseIP(host); ip != nil { + if isBlockedDeployIP(ip) { + return fmt.Errorf("git_url host %q is not an allowed address", host) + } + return nil + } + ips, err := gitHostLookupIP(host) + if err != nil || len(ips) == 0 { + return fmt.Errorf("git_url host %q could not be resolved", host) + } + for _, ip := range ips { + if isBlockedDeployIP(ip) { + return fmt.Errorf("git_url host %q resolves to a disallowed internal address", host) + } + } + return nil +} + +// applyGitSourceOpts populates DeployOptions for a source=git deployment from +// the persisted row: sets Source/GitURL/GitRef, clears Tarball, and decrypts the +// optional private-repo token into GitAuth. A decrypt failure (or bad key) is +// logged and falls back to an unauthenticated clone — public repos still build. +// No-op for non-git sources. +func applyGitSourceOpts(opts *compute.DeployOptions, d *models.Deployment, aesKeyHex string) { + if d.Source != "git" { + return + } + opts.Source = "git" + opts.GitURL = d.GitURL + opts.GitRef = d.GitRef + opts.Tarball = nil + if d.GitTokenEnc == "" { + return + } + key, kerr := crypto.ParseAESKey(aesKeyHex) + if kerr != nil { + slog.Error("deploy.git.aes_key_invalid", "app_id", d.AppID, "error", kerr) + return + } + plain, derr := crypto.Decrypt(key, d.GitTokenEnc) + if derr != nil { + slog.Error("deploy.run_deploy.git_token_decrypt_failed", "app_id", d.AppID, "error", derr) + return + } + opts.GitAuth = plain +} + +// encryptDeploySecret AES-256-GCM-encrypts a sensitive deploy input (a BYO +// private-registry docker config JSON, or a private-repo git token) for at-rest +// storage. ParseAESKey is the only failure mode worth a distinct branch (a +// misconfigured/short AES_KEY); crypto.Encrypt over a valid key does not fail +// for these inputs, so its error is returned verbatim without a separate +// handler branch. Returns the ciphertext for persistence. +func encryptDeploySecret(aesKeyHex, plaintext string) (string, error) { key, err := crypto.ParseAESKey(aesKeyHex) if err != nil { return "", err @@ -398,6 +513,13 @@ func deploymentToMapWithDB(d *models.Deployment, db *sql.DB) fiber.Map { m["image_ref"] = d.ImageRef m["registry_creds_set"] = d.RegistryCredsEnc != "" } + if d.Source == "git" { + // git_url + git_ref are caller-supplied (no secret) and echoed back; + // git_token is NEVER returned — only git_token_set lifecycle metadata. + m["git_url"] = d.GitURL + m["git_ref"] = d.GitRef + m["git_token_set"] = d.GitTokenEnc != "" + } if d.NotifyWebhook != "" { m["notify_attempts"] = d.NotifyAttempts m["notify_secret_set"] = d.NotifyWebhookSecret != "" @@ -618,9 +740,11 @@ func (h *DeployHandler) runDeploy(d *models.Deployment, tarball []byte) { Private: d.Private, AllowedIPs: d.AllowedIPs, } - // Multi-source (migration 064): a source=image deploy carries no tarball — - // the compute layer deploys d.ImageRef directly (skip Kaniko). + // Multi-source (migration 064/065): image deploys carry no tarball (deploy + // d.ImageRef directly); git deploys carry no tarball (Kaniko builds the repo + // via git context). Each helper is a no-op unless its source matches. applyImageSourceOpts(&opts, d, h.cfg.AESKey) + applyGitSourceOpts(&opts, d, h.cfg.AESKey) result, err := h.compute.Deploy(ctx, opts) if err != nil { slog.Error("deploy.run_deploy.failed", @@ -703,6 +827,7 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { var tarball []byte var imageRef, registryCredsEnc string + var gitURL, gitRef, gitTokenEnc string switch source { case "image": @@ -726,13 +851,45 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { // config JSON, encrypted at rest (AES-256-GCM, like notify_webhook_secret) // and never echoed back. Absent → the platform ghcr-pull secret is used. if vals := form.Value["registry_creds"]; len(vals) > 0 && strings.TrimSpace(vals[0]) != "" { - enc, encErr := encryptRegistryCreds(h.cfg.AESKey, strings.TrimSpace(vals[0])) + enc, encErr := encryptDeploySecret(h.cfg.AESKey, strings.TrimSpace(vals[0])) if encErr != nil { return respondError(c, fiber.StatusServiceUnavailable, "encrypt_failed", "Could not secure registry credentials") } registryCredsEnc = enc } + case "git": + // Flag-gated (P3): the git-context build path is off until an operator + // enables it post-canary (DEPLOY_SOURCE_GIT_ENABLED=true). Until then + // reject cleanly so tarball/image deploys are never affected. + if !h.cfg.DeploySourceGitEnabled { + return respondError(c, fiber.StatusNotImplemented, "source_git_disabled", + "Deploying from a git repo (source=git) is rolling out and not yet enabled. Upload source (tarball ≤10MB) or use source=image for now.") + } + raw := "" + if vals := form.Value["git_url"]; len(vals) > 0 { + raw = strings.TrimSpace(vals[0]) + } + validURL, urlErr := validateGitURL(raw) + if urlErr != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_git_url", urlErr.Error()) + } + gitURL = validURL + // Optional ref (branch/tag/SHA). Empty → Kaniko builds the default branch. + if vals := form.Value["git_ref"]; len(vals) > 0 { + gitRef = strings.TrimSpace(vals[0]) + } + // Optional read-only token for a PRIVATE repo, encrypted at rest (same + // posture as registry_creds) and never echoed back. Absent → the repo + // is treated as public. + if vals := form.Value["git_token"]; len(vals) > 0 && strings.TrimSpace(vals[0]) != "" { + enc, encErr := encryptDeploySecret(h.cfg.AESKey, strings.TrimSpace(vals[0])) + if encErr != nil { + return respondError(c, fiber.StatusServiceUnavailable, "encrypt_failed", + "Could not secure the git access token") + } + gitTokenEnc = enc + } case "tarball": tarballs := form.File["tarball"] if len(tarballs) == 0 { @@ -759,7 +916,7 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { tarball = b default: return respondError(c, fiber.StatusBadRequest, "invalid_source", - "Field 'source' must be 'tarball' (default) or 'image'") + "Field 'source' must be 'tarball' (default), 'image', or 'git'") } // Required name field — the human-readable deployment label. @@ -1091,6 +1248,9 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { Source: source, ImageRef: imageRef, RegistryCredsEnc: registryCredsEnc, + GitURL: gitURL, + GitRef: gitRef, + GitTokenEnc: gitTokenEnc, }) if errors.Is(err, models.ErrDeploymentCapReached) { // Over the per-tier cap — surfaced atomically inside the diff --git a/internal/handlers/deploy_image_source_test.go b/internal/handlers/deploy_image_source_test.go index f198c768..2da7c283 100644 --- a/internal/handlers/deploy_image_source_test.go +++ b/internal/handlers/deploy_image_source_test.go @@ -3,6 +3,8 @@ package handlers // deploy_image_source_test.go — unit tests for P2 source=image validation. import ( + "errors" + "net" "strings" "testing" @@ -25,11 +27,11 @@ func TestValidateImageRef(t *testing.T) { } } bad := map[string]string{ - "": "empty", - "nginx": "bare name (no host)", - "owner/repo:tag": "Docker Hub implicit namespace (no explicit host)", - "owner/repo": "no host, no tag", - "ghcr.io/o/a:t ag": "whitespace", + "": "empty", + "nginx": "bare name (no host)", + "owner/repo:tag": "Docker Hub implicit namespace (no explicit host)", + "owner/repo": "no host, no tag", + "ghcr.io/o/a:t ag": "whitespace", } for ref, why := range bad { if _, err := validateImageRef(ref); err == nil { @@ -85,16 +87,16 @@ func TestValidateImageRef_TooLong(t *testing.T) { } } -func TestEncryptRegistryCreds(t *testing.T) { +func TestEncryptDeploySecret(t *testing.T) { const keyHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" // bad key (not 64 hex chars) → ParseAESKey error path. - if _, err := encryptRegistryCreds("tooshort", `{"auths":{}}`); err == nil { + if _, err := encryptDeploySecret("tooshort", `{"auths":{}}`); err == nil { t.Error("a malformed AES key must return an error") } // good key → ciphertext that round-trips. - ct, err := encryptRegistryCreds(keyHex, `{"auths":{"ghcr.io":{}}}`) + ct, err := encryptDeploySecret(keyHex, `{"auths":{"ghcr.io":{}}}`) if err != nil { - t.Fatalf("encryptRegistryCreds(good key): %v", err) + t.Fatalf("encryptDeploySecret(good key): %v", err) } if ct == "" { t.Fatal("ciphertext must be non-empty") @@ -146,3 +148,128 @@ func TestApplyImageSourceOpts(t *testing.T) { t.Error("bad AES key must not set RegistryAuth") } } + +func TestValidateGitURL(t *testing.T) { + // Stub DNS so hostname cases are deterministic: every name resolves to a + // public IP. Literal-IP cases below bypass this (net.ParseIP short-circuits). + orig := gitHostLookupIP + gitHostLookupIP = func(string) ([]net.IP, error) { return []net.IP{net.ParseIP("93.184.216.34")}, nil } + defer func() { gitHostLookupIP = orig }() + + ok := []string{ + "https://github.com/owner/repo", + "https://github.com/owner/repo.git", + "http://gitlab.example.com:8080/group/proj", + "https://bitbucket.org/team/repo", + "https://93.184.216.34/owner/repo", // public literal IP (no DNS, not blocked) + } + for _, u := range ok { + if got, err := validateGitURL(u); err != nil || got != u { + t.Errorf("validateGitURL(%q) = (%q,%v), want (%q,nil)", u, got, err, u) + } + } + bad := map[string]string{ + "": "empty", + "git@github.com:owner/repo.git": "ssh scheme", + "git://github.com/owner/repo": "git scheme", + "ftp://h/x": "non-http scheme", + "https://": "no host", + "https://u:p@github.com/owner/repo": "embedded credentials", + "https://github.com/owner/repo a": "whitespace", + // SSRF: literal internal IPs (no DNS) must be rejected. + "http://127.0.0.1/o/r": "loopback", + "http://169.254.169.254/o/r": "cloud metadata (link-local)", + "http://10.0.0.5/o/r": "RFC1918 10/8", + "http://192.168.1.1/o/r": "RFC1918 192.168/16", + "http://172.16.5.5/o/r": "RFC1918 172.16/12", + "http://[::1]/o/r": "IPv6 loopback", + "http://0.0.0.0/o/r": "unspecified", + "http://:8080/o/r": "host with port but empty hostname", + } + for u, why := range bad { + if _, err := validateGitURL(u); err == nil { + t.Errorf("validateGitURL(%q) should fail (%s) but passed", u, why) + } + } + // over-length + if _, err := validateGitURL("https://github.com/o/" + strings.Repeat("a", 600)); err == nil { + t.Error("an over-length git_url must be rejected") + } + + // SSRF: a hostname that RESOLVES to an internal IP is rejected. + gitHostLookupIP = func(string) ([]net.IP, error) { return []net.IP{net.ParseIP("10.1.2.3")}, nil } + if _, err := validateGitURL("https://evil.example.com/o/r"); err == nil { + t.Error("a host resolving to an internal IP must be rejected") + } + // SSRF: resolution failure is fail-closed. + gitHostLookupIP = func(string) ([]net.IP, error) { return nil, errors.New("nxdomain") } + if _, err := validateGitURL("https://nope.example.com/o/r"); err == nil { + t.Error("an unresolvable host must be rejected (fail-closed)") + } +} + +func TestApplyGitSourceOpts(t *testing.T) { + const keyHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + key, _ := crypto.ParseAESKey(keyHex) + cipher, _ := crypto.Encrypt(key, "ghp_token") + + // non-git → no-op + o := compute.DeployOptions{Tarball: []byte("x")} + applyGitSourceOpts(&o, &models.Deployment{Source: "tarball"}, keyHex) + if o.Source != "" || o.Tarball == nil { + t.Errorf("non-git must be untouched, got %+v", o) + } + + // git, no token → Source/GitURL/GitRef set, Tarball cleared, no GitAuth + o = compute.DeployOptions{Tarball: []byte("x")} + applyGitSourceOpts(&o, &models.Deployment{Source: "git", GitURL: "https://github.com/o/r", GitRef: "main"}, keyHex) + if o.Source != "git" || o.GitURL != "https://github.com/o/r" || o.GitRef != "main" || o.Tarball != nil || o.GitAuth != "" { + t.Errorf("git no-token: %+v", o) + } + + // git + token → GitAuth decrypted + o = compute.DeployOptions{} + applyGitSourceOpts(&o, &models.Deployment{Source: "git", GitURL: "r", GitTokenEnc: cipher}, keyHex) + if o.GitAuth != "ghp_token" { + t.Errorf("token decrypt: got %q", o.GitAuth) + } + + // git + bad ciphertext → no GitAuth (fallback to public clone) + o = compute.DeployOptions{} + applyGitSourceOpts(&o, &models.Deployment{Source: "git", GitTokenEnc: "not-valid-base64!!"}, keyHex) + if o.GitAuth != "" { + t.Error("bad ciphertext must not set GitAuth") + } + + // bad AES key → no GitAuth + o = compute.DeployOptions{} + applyGitSourceOpts(&o, &models.Deployment{Source: "git", GitTokenEnc: cipher}, "tooshort") + if o.GitAuth != "" { + t.Error("bad AES key must not set GitAuth") + } +} + +func TestDeploymentToMap_GitSource(t *testing.T) { + g := &models.Deployment{Source: "git", GitURL: "https://github.com/o/r", GitRef: "main", GitTokenEnc: "ciphertext", EnvVars: map[string]string{}} + m := deploymentToMap(g) + if m["source"] != "git" { + t.Errorf("source: got %v want git", m["source"]) + } + if m["git_url"] != "https://github.com/o/r" { + t.Errorf("git_url: got %v", m["git_url"]) + } + if m["git_ref"] != "main" { + t.Errorf("git_ref: got %v", m["git_ref"]) + } + if m["git_token_set"] != true { + t.Errorf("git_token_set: got %v want true", m["git_token_set"]) + } + if _, leaked := m["git_token"]; leaked { + t.Error("git_token must never appear in the response map") + } + // tarball deploy emits no git_* keys + tb := &models.Deployment{Source: "tarball", EnvVars: map[string]string{}} + if _, ok := deploymentToMap(tb)["git_url"]; ok { + t.Error("tarball deploy must not emit git_url") + } +} diff --git a/internal/handlers/deploy_redeploy_inplace_mock_test.go b/internal/handlers/deploy_redeploy_inplace_mock_test.go index d6667c39..dcd86482 100644 --- a/internal/handlers/deploy_redeploy_inplace_mock_test.go +++ b/internal/handlers/deploy_redeploy_inplace_mock_test.go @@ -76,6 +76,7 @@ var deploymentColumnsList = []string{ "notify_webhook", "notify_webhook_secret", "notify_state", "notify_attempts", "expires_at", "ttl_policy", "reminders_sent", "last_reminder_at", "source", "image_ref", "registry_creds_enc", + "git_url", "git_ref", "git_token_enc", } // redeployMockApp wires a minimal Fiber app that drives DeployHandler.New @@ -254,6 +255,7 @@ func TestDeployNew_Redeploy_WrongTeam_DefenceInDepth(t *testing.T) { sql.NullString{}, sql.NullString{}, "unset", 0, // notify_* sql.NullTime{}, "permanent", 0, sql.NullTime{}, // ttl_* "tarball", "", "", // source, image_ref, registry_creds_enc (mig 064) + "", "", "", // git_url, git_ref, git_token_enc (mig 065) )) body, ct := multipartRedeployMockBody(t, map[string]string{ @@ -325,6 +327,7 @@ func TestDeployNew_Redeploy_UpdateStatusError_StillAccepts(t *testing.T) { sql.NullString{}, sql.NullString{}, "unset", 0, sql.NullTime{}, "permanent", 0, sql.NullTime{}, "tarball", "", "", // source, image_ref, registry_creds_enc (mig 064) + "", "", "", // git_url, git_ref, git_token_enc (mig 065) )) // UPDATE deployments SET status = $1 ... → driver error. The handler @@ -414,6 +417,7 @@ func TestDeployNew_Redeploy_EmptyProviderID_Returns409(t *testing.T) { sql.NullString{}, sql.NullString{}, "unset", 0, sql.NullTime{}, "permanent", 0, sql.NullTime{}, "tarball", "", "", // source, image_ref, registry_creds_enc (mig 064) + "", "", "", // git_url, git_ref, git_token_enc (mig 065) )) body, ct := multipartRedeployMockBody(t, map[string]string{ diff --git a/internal/handlers/deploy_source_git_integration_test.go b/internal/handlers/deploy_source_git_integration_test.go new file mode 100644 index 00000000..49da09e9 --- /dev/null +++ b/internal/handlers/deploy_source_git_integration_test.go @@ -0,0 +1,180 @@ +package handlers_test + +// deploy_source_git_integration_test.go — end-to-end coverage for the P3 +// source=git branch of POST /deploy/new (migration 065): flag-off 501, flag-on +// invalid git_url 400, encrypt-failure 503, and the flag-on happy 202 (git_url/ +// git_ref echoed, git_token never echoed, async runDeploy drives the row +// healthy via the noop provider). Reuses buildImageDeployForm/postDeploy from +// deploy_source_image_integration_test.go. DB-gated; skips locally without one. + +import ( + "database/sql" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/testhelpers" +) + +func TestDeployNew_SourceGit_FlagOff_501(t *testing.T) { + daDeployNeedsDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamID, "git@example.com") + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy") // flag defaults OFF + defer cleanApp() + + body, ct := buildImageDeployForm(t, map[string]string{ + "name": "git-app", + "source": "git", + "git_url": "https://github.com/owner/repo", + }) + resp := postDeploy(t, app, body, ct, jwt) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotImplemented, resp.StatusCode, "source=git must 501 while flag is off") + var env struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&env)) + assert.Equal(t, "source_git_disabled", env.Error) +} + +func TestDeployNew_SourceGit_FlagOn_InvalidURL_400(t *testing.T) { + daDeployNeedsDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamID, "badurl@example.com") + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy", + func(c *config.Config) { c.DeploySourceGitEnabled = true }) + defer cleanApp() + + body, ct := buildImageDeployForm(t, map[string]string{ + "name": "bad-git-app", + "source": "git", + "git_url": "git@github.com:owner/repo.git", // ssh scheme → rejected + }) + resp := postDeploy(t, app, body, ct, jwt) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + var env struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&env)) + assert.Equal(t, "invalid_git_url", env.Error) +} + +// bad AES key → encrypting the private-repo token fails → 503. +func TestDeployNew_SourceGit_EncryptFailure_503(t *testing.T) { + daDeployNeedsDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamID, "gitenc@example.com") + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy", + func(c *config.Config) { + c.DeploySourceGitEnabled = true + c.AESKey = "not-a-valid-hex-key" + }) + defer cleanApp() + + body, ct := buildImageDeployForm(t, map[string]string{ + "name": "git-enc-fail", + "source": "git", + "git_url": "https://github.com/owner/repo", + "git_token": "ghp_secret", + }) + resp := postDeploy(t, app, body, ct, jwt) + defer resp.Body.Close() + + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + var env struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&env)) + assert.Equal(t, "encrypt_failed", env.Error) +} + +// happy path: flag on, valid git_url + ref + private token → 202; the response +// echoes source=git + git_url + git_ref + git_token_set:true (never the token), +// and the async runDeploy drives the row healthy via the noop provider. +func TestDeployNew_SourceGit_FlagOn_Accepted(t *testing.T) { + daDeployNeedsDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamID, "gitok@example.com") + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy", + func(c *config.Config) { c.DeploySourceGitEnabled = true }) + defer cleanApp() + + const gitURL = "https://github.com/owner/repo" + body, ct := buildImageDeployForm(t, map[string]string{ + "name": "git-ok-app", + "source": "git", + "git_url": gitURL, + "git_ref": "main", + "git_token": "ghp_secrettoken", + }) + resp := postDeploy(t, app, body, ct, jwt) + defer resp.Body.Close() + + require.Equal(t, http.StatusAccepted, resp.StatusCode, "valid source=git deploy must 202") + var env struct { + OK bool `json:"ok"` + Item struct { + ID string `json:"id"` + Source string `json:"source"` + GitURL string `json:"git_url"` + GitRef string `json:"git_ref"` + GitTokenSet bool `json:"git_token_set"` + GitToken string `json:"git_token"` + } `json:"item"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&env)) + assert.True(t, env.OK) + assert.Equal(t, "git", env.Item.Source) + assert.Equal(t, gitURL, env.Item.GitURL) + assert.Equal(t, "main", env.Item.GitRef) + assert.True(t, env.Item.GitTokenSet, "git_token_set must be true when a token is supplied") + assert.Empty(t, env.Item.GitToken, "git token must NEVER be echoed back") + require.NotEmpty(t, env.Item.ID) + + // runDeploy is async — poll the row until the noop provider stamps it + // healthy with a provider id (proves applyGitSourceOpts → compute.Deploy ran). + deadline := time.Now().Add(5 * time.Second) + var status, providerID string + var gitURLCol sql.NullString + for time.Now().Before(deadline) { + row := db.QueryRow(`SELECT status, COALESCE(provider_id,''), git_url FROM deployments WHERE id = $1`, env.Item.ID) + require.NoError(t, row.Scan(&status, &providerID, &gitURLCol)) + if providerID != "" { + break + } + time.Sleep(50 * time.Millisecond) + } + assert.Equal(t, "healthy", status, "noop provider should drive the row to healthy") + assert.NotEmpty(t, providerID, "runDeploy must persist the provider id") + assert.Equal(t, gitURL, gitURLCol.String, "git_url must be persisted on the row") +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index bb416124..9410bc0b 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -493,7 +493,13 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user the deployment tarball is missing. POST a multipart form with 'tarball' (.tar.gz, <=50 MiB) — see https://instanode.dev/docs/deploy.", }, "invalid_source": { - AgentAction: "Tell the user the 'source' field is invalid. Use source=tarball (default — upload a .tar.gz) or source=image (deploy a prebuilt image_ref) — see https://instanode.dev/docs/deploy.", + AgentAction: "Tell the user the 'source' field is invalid. Use source=tarball (default — upload a .tar.gz), source=image (deploy a prebuilt image_ref), or source=git (build a repo URL) — see https://instanode.dev/docs/deploy.", + }, + "source_git_disabled": { + AgentAction: "Tell the user deploying from a git repo (source=git) is still rolling out and not yet enabled. Upload source as a tarball (<=10 MiB) or use source=image for now — see https://instanode.dev/docs/deploy.", + }, + "invalid_git_url": { + AgentAction: "Tell the user the git_url is invalid. Pass an http(s) clone URL with a host and no embedded credentials, e.g. https://github.com/owner/repo — for a private repo send the token via git_token — see https://instanode.dev/docs/deploy.", }, "source_image_disabled": { AgentAction: "Tell the user deploying from a prebuilt image (source=image) is still rolling out and not yet enabled. Upload source as a tarball (<=10 MiB) for now — see https://instanode.dev/docs/deploy.", diff --git a/internal/models/coverage_provision_gate_test.go b/internal/models/coverage_provision_gate_test.go index 5ae06ed2..ee4f72b6 100644 --- a/internal/models/coverage_provision_gate_test.go +++ b/internal/models/coverage_provision_gate_test.go @@ -18,6 +18,7 @@ func deploymentMockCols() []string { "notify_webhook", "notify_webhook_secret", "notify_state", "notify_attempts", "expires_at", "ttl_policy", "reminders_sent", "last_reminder_at", "source", "image_ref", "registry_creds_enc", + "git_url", "git_ref", "git_token_enc", } } @@ -28,6 +29,7 @@ func deploymentMockRow() *sqlmock.Rows { nil, nil, "unset", 0, nil, "auto_24h", 0, nil, "tarball", "", "", // source, image_ref, registry_creds_enc (mig 064) + "", "", "", // git_url, git_ref, git_token_enc (mig 065) ) } diff --git a/internal/models/deployment.go b/internal/models/deployment.go index 3c305eaf..4a5bb466 100644 --- a/internal/models/deployment.go +++ b/internal/models/deployment.go @@ -50,6 +50,10 @@ type Deployment struct { Source string // 'tarball' (default) | 'image' | 'git' ImageRef string // prebuilt image ref when Source=='image'; '' otherwise RegistryCredsEnc string // AES-256-GCM ciphertext of pull creds; '' when public / platform secret + // Git source deploys (migration 065). + GitURL string // clone URL when Source=='git'; '' otherwise + GitRef string // branch/tag/SHA to build; '' = provider default branch + GitTokenEnc string // AES-256-GCM ciphertext of read-only token for a private repo; '' when public // TTL fields (Wave FIX-J — migration 045). // // ExpiresAt: when the deploy auto-expires. Zero (sql NULL) means @@ -102,6 +106,10 @@ type CreateDeploymentParams struct { Source string // 'tarball' | 'image' | 'git' ImageRef string // prebuilt image ref when Source=='image' RegistryCredsEnc string // AES-256-GCM ciphertext of pull creds; '' when public + // Git source deploys (migration 065). + GitURL string // clone URL when Source=='git' + GitRef string // branch/tag/SHA; '' = default branch + GitTokenEnc string // AES-256-GCM ciphertext of private-repo token; '' when public // TTLPolicy chooses the lifecycle for this deploy. Valid values are // "auto_24h" (default — expires_at set to now()+24h), "permanent" // (expires_at = NULL, never auto-expires), or "custom" (caller sets @@ -144,7 +152,8 @@ const deploymentColumns = `id, team_id, resource_id, app_id, provider_id, status env_vars, port, tier, env, private, allowed_ips, error_message, created_at, updated_at, notify_webhook, notify_webhook_secret, notify_state, notify_attempts, expires_at, ttl_policy, reminders_sent, last_reminder_at, - source, image_ref, registry_creds_enc` + source, image_ref, registry_creds_enc, + git_url, git_ref, git_token_enc` // scanDeployment reads a single deployments row into a Deployment struct. // env_vars is stored as JSONB; error_message, provider_id, and app_url are nullable. @@ -177,6 +186,9 @@ func scanDeployment(row interface { // migration 064: multi-source deploys. NOT NULL DEFAULT '' / 'tarball' // so plain string scan targets are safe (no NullString needed). &d.Source, &d.ImageRef, &d.RegistryCredsEnc, + // migration 065: git source deploys. NOT NULL DEFAULT '' — plain string + // scan targets are safe. + &d.GitURL, &d.GitRef, &d.GitTokenEnc, ); err != nil { return nil, err } @@ -313,14 +325,16 @@ func CreateDeployment(ctx context.Context, db dbExecutor, p CreateDeploymentPara (team_id, resource_id, app_id, port, tier, env, env_vars, private, allowed_ips, notify_webhook, notify_webhook_secret, notify_state, expires_at, ttl_policy, - source, image_ref, registry_creds_enc) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + source, image_ref, registry_creds_enc, + git_url, git_ref, git_token_enc) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING `+deploymentColumns, p.TeamID, resourceID, p.AppID, port, p.Tier, env, envVarsJSON, p.Private, allowedIPs, notifyWebhook, notifyWebhookSecret, notifyState, expiresAt, ttlPolicy, - source, p.ImageRef, p.RegistryCredsEnc) + source, p.ImageRef, p.RegistryCredsEnc, + p.GitURL, p.GitRef, p.GitTokenEnc) d, err := scanDeployment(row) if err != nil { diff --git a/internal/providers/compute/k8s/client.go b/internal/providers/compute/k8s/client.go index e4ec7ab6..a0576671 100644 --- a/internal/providers/compute/k8s/client.go +++ b/internal/providers/compute/k8s/client.go @@ -284,6 +284,15 @@ const ( var ( defaultClusterPodCIDRs = []string{"10.42.0.0/16", "10.244.0.0/16"} defaultClusterServiceCIDRs = []string{"10.43.0.0/16", "10.245.0.0/16"} + // privateEgressExceptCIDRs are RFC1918 + loopback ranges denied to build + // pods (SSRF defense-in-depth). Link-local/metadata is covered separately by + // metadataCIDR (169.254.0.0/16). + privateEgressExceptCIDRs = []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + } ) // egressExceptCIDRs returns the IPBlock.Except list for the customer-deploy @@ -301,10 +310,17 @@ func egressExceptCIDRs() []string { svcs = splitCIDRList(v) } - except := make([]string, 0, len(pods)+len(svcs)+1) + except := make([]string, 0, len(pods)+len(svcs)+1+len(privateEgressExceptCIDRs)) except = append(except, pods...) except = append(except, svcs...) except = append(except, metadataCIDR) + // Defense-in-depth SSRF guard: deny build-pod egress to all loopback + + // RFC1918 private ranges (not just the cluster pod/service CIDRs), so a + // source=git/tarball build can never reach an internal service the cluster + // CIDRs don't cover. Public SaaS build pods only need public egress (GHCR + + // public git hosts), so this is safe. App-layer validateGitURL screens the + // host too; this is the runtime backstop (incl. DNS-rebinding). + except = append(except, privateEgressExceptCIDRs...) return except } @@ -1050,7 +1066,8 @@ func (p *K8sProvider) Deploy(ctx context.Context, opts compute.DeployOptions) (* ns := deployNamespace(opts.AppID) var imageTag string - if opts.Source == "image" { + switch opts.Source { + case "image": // BYO prebuilt image — skip Kaniko entirely. No build Job, no MinIO // upload, no build NetworkPolicy. We deploy opts.ImageRef directly. imageTag = opts.ImageRef @@ -1065,7 +1082,18 @@ func (p *K8sProvider) Deploy(ctx context.Context, opts compute.DeployOptions) (* if err := p.ensureImagePullSecret(ctx, ns, opts.RegistryAuth); err != nil { return nil, fmt.Errorf("k8s.Deploy(image): pull secret: %w", err) } - } else { + case "git": + // source=git (P3): kaniko clones + builds the repo via its git context — + // no tarball upload. Build first, then setupTenantNamespace (same order + // as tarball, so the build pod isn't constrained by the runtime quota). + imageTag = imageName(opts.AppID) + if err := p.buildImageFromGit(ctx, ns, opts.AppID, imageTag, opts.GitURL, opts.GitRef, opts.GitAuth); err != nil { + return nil, fmt.Errorf("k8s.Deploy(git): build image: %w", err) + } + if err := p.setupTenantNamespace(ctx, ns, opts.AppID, opts.TeamID, opts.Tier); err != nil { + return nil, fmt.Errorf("k8s.Deploy(git): setup namespace: %w", err) + } + default: imageTag = imageName(opts.AppID) // Step 1: Build the Docker image from the tarball. if err := p.buildImage(ctx, ns, opts.AppID, imageTag, opts.Tarball); err != nil { @@ -1490,7 +1518,7 @@ func (p *K8sProvider) buildImage(ctx context.Context, ns, appID, imageTag string _ = p.clientset.BatchV1().Jobs(ns).Delete(ctx, jobName, metav1.DeleteOptions{ PropagationPolicy: &prop, }) - if err := p.createKanikoJob(ctx, ns, jobName, ctxSecret, authSecret, imageTag, s3URL); err != nil { + if err := p.createKanikoJob(ctx, ns, jobName, ctxSecret, authSecret, imageTag, s3URL, "", ""); err != nil { return fmt.Errorf("k8s.buildImage: create kaniko job: %w", err) } @@ -1511,6 +1539,136 @@ func (p *K8sProvider) buildImage(ctx context.Context, ns, appID, imageTag string return nil } +// kanikoContextArg returns the kaniko --context flag: a git:// context for +// source=git, or the legacy tar context for tarball/http builds. +func kanikoContextArg(useGit bool, gitContext string) string { + if useGit { + return "--context=" + gitContext + } + return "--context=tar:///workspace/context.tar.gz" +} + +// kanikoGitEnv returns the GIT_USERNAME/GIT_PASSWORD env kaniko reads to clone a +// PRIVATE git context. Empty for non-git builds or public repos (no secret). +// GIT_USERNAME is a constant placeholder — GitHub/GitLab token auth ignores the +// username and authenticates on the token in GIT_PASSWORD. +func kanikoGitEnv(useGit bool, gitAuthSecret string) []corev1.EnvVar { + if !useGit || gitAuthSecret == "" { + return nil + } + return []corev1.EnvVar{ + {Name: "GIT_USERNAME", Value: "x-access-token"}, + {Name: "GIT_PASSWORD", ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: gitAuthSecret}, + Key: "token", + }, + }}, + } +} + +// gitCloneContext converts a validated https clone URL + optional ref into the +// kaniko git context string: git://host/path[#ref]. The handler already +// validated the URL is http(s) with a host and no embedded creds. +func gitCloneContext(gitURL, gitRef string) string { + ctxURL := gitURL + if i := strings.Index(ctxURL, "://"); i >= 0 { + ctxURL = ctxURL[i+3:] // strip scheme; kaniko prepends git:// + } + ctxURL = "git://" + ctxURL + if gitRef != "" { + ctxURL += "#" + gitRef + } + return ctxURL +} + +// buildImageFromGit builds imageTag from a git repo (source=git, P3) using +// kaniko's native git context — no tarball upload. It mirrors buildImage's +// namespace + build-NetworkPolicy + registry-auth prep (deliberately inline +// rather than refactored out of the proven tarball path), then runs a kaniko +// Job whose context is the repo. A private repo's token is written to a +// short-lived git-auth Secret consumed via GIT_PASSWORD. +func (p *K8sProvider) buildImageFromGit(ctx context.Context, ns, appID, imageTag, gitURL, gitRef, gitAuth string) error { + jobName := "build-" + sanitizeName(appID) + authSecret := "ghcr-pull" + + slog.Info("k8s.buildImageFromGit: starting kaniko git build", + "app_id", appID, "image", imageTag, "namespace", ns, "git_url", gitURL, "git_ref", gitRef) + + // 0. Ensure the namespace exists with PSS labels (mirrors buildImage step 0). + nsObj := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: ns, + Labels: map[string]string{ + "managed-by": "instant.dev", + "instant.dev/component": "build-staging", + pssEnforceLabel: pssBaseline, + pssWarnLabel: pssRestricted, + }, + }} + if _, err := p.clientset.CoreV1().Namespaces().Create(ctx, nsObj, metav1.CreateOptions{}); err != nil { + if !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("k8s.buildImageFromGit: ensure namespace %q: %w", ns, err) + } + if uerr := p.upgradeNamespaceLabels(ctx, ns, nsObj.Labels); uerr != nil { + return fmt.Errorf("k8s.buildImageFromGit: upgrade namespace labels %q: %w", ns, uerr) + } + } + + // 0b. Build-scoped default-deny NetworkPolicy before the Job (kaniko still + // needs the git host + registry egress, which the build NP allows). + if err := p.createBuildNetworkPolicy(ctx, ns); err != nil { + return fmt.Errorf("k8s.buildImageFromGit: %w", err) + } + + // 1. Registry auth for pushing the built image (copied from instant ns). + if err := p.ensureRegistryAuthInNS(ctx, ns, authSecret); err != nil { + return fmt.Errorf("k8s.buildImageFromGit: registry auth: %w", err) + } + + // 2. Optional private-repo token → short-lived git-auth Secret, deleted with + // the build regardless of outcome. + gitAuthSecret := "" + if gitAuth != "" { + gitAuthSecret = "git-auth-" + sanitizeName(appID) + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: gitAuthSecret}, + Data: map[string][]byte{"token": []byte(gitAuth)}, + } + if _, err := p.clientset.CoreV1().Secrets(ns).Create(ctx, sec, metav1.CreateOptions{}); err != nil { + if !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("k8s.buildImageFromGit: git auth secret: %w", err) + } + if _, uerr := p.clientset.CoreV1().Secrets(ns).Update(ctx, sec, metav1.UpdateOptions{}); uerr != nil { + return fmt.Errorf("k8s.buildImageFromGit: git auth secret update: %w", uerr) + } + } + defer func() { + delCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if delErr := p.clientset.CoreV1().Secrets(ns).Delete(delCtx, gitAuthSecret, metav1.DeleteOptions{}); delErr != nil && !apierrors.IsNotFound(delErr) { + slog.Warn("k8s.buildImageFromGit.cleanup_secret_failed", "namespace", ns, "name", gitAuthSecret, "error", delErr) + } + }() + } + + // 3. Create the kaniko git-context Job (delete any stale prior attempt). + prop := metav1.DeletePropagationBackground + _ = p.clientset.BatchV1().Jobs(ns).Delete(ctx, jobName, metav1.DeleteOptions{PropagationPolicy: &prop}) + gitContext := gitCloneContext(gitURL, gitRef) + if err := p.createKanikoJob(ctx, ns, jobName, "", authSecret, imageTag, "", gitContext, gitAuthSecret); err != nil { + return fmt.Errorf("k8s.buildImageFromGit: create kaniko job: %w", err) + } + + // 4. Wait for completion; snapshot logs on failure for the autopsy. + if err := p.waitForJobComplete(ctx, ns, jobName, 10*time.Minute); err != nil { + p.snapshotBuildLogs(ctx, ns, appID, jobName) + return fmt.Errorf("k8s.buildImageFromGit: kaniko job: %w", err) + } + + slog.Info("k8s.buildImageFromGit: kaniko git build complete", "app_id", appID, "image", imageTag) + return nil +} + // sanitizeName lowercases and DNS-1123-cleans an appID for use in resource names. func sanitizeName(s string) string { out := make([]byte, 0, len(s)) @@ -1639,11 +1797,16 @@ func (p *K8sProvider) ensureImagePullSecret(ctx context.Context, ns, registryAut // Why not --context=https://: MinIO is plaintext HTTP in-cluster, kaniko's // HTTP context list does not include http://. The init-container sidesteps // both — we control the fetch, kaniko sees a local tar volume. -func (p *K8sProvider) createKanikoJob(ctx context.Context, ns, jobName, ctxSecret, authSecret, imageTag, httpContextURL string) error { +func (p *K8sProvider) createKanikoJob(ctx context.Context, ns, jobName, ctxSecret, authSecret, imageTag, httpContextURL, gitContext, gitAuthSecret string) error { backoff := int32(0) ttl := int32(300) - useHTTP := httpContextURL != "" + // source=git (P3): kaniko clones the repo itself via its git context, so + // there is NO build-context volume, init-container, or context Secret — + // only the registry-auth mount (to push the built image) and optional GIT_* + // env for a private repo. useHTTP/Secret paths are mutually exclusive with it. + useGit := gitContext != "" + useHTTP := !useGit && httpContextURL != "" volumes := []corev1.Volume{{ Name: "registry-auth", @@ -1661,7 +1824,10 @@ func (p *K8sProvider) createKanikoJob(ctx context.Context, ns, jobName, ctxSecre } var initContainers []corev1.Container - if useHTTP { + switch { + case useGit: + // No build-context volume: kaniko fetches the repo into its own workdir. + case useHTTP: // Shared emptyDir between init-container (curl) and main kaniko container. volumes = append(volumes, corev1.Volume{ Name: "build-context", @@ -1692,7 +1858,7 @@ func (p *K8sProvider) createKanikoJob(ctx context.Context, ns, jobName, ctxSecre }, }, }} - } else { + default: // Legacy Secret path (≤1 MiB). volumes = append(volumes, corev1.Volume{ Name: "build-context", @@ -1734,8 +1900,13 @@ func (p *K8sProvider) createKanikoJob(ctx context.Context, ns, jobName, ctxSecre Containers: []corev1.Container{{ Name: "kaniko", Image: "gcr.io/kaniko-project/executor:v1.23.2", + // source=git: kaniko clones gitContext (git://host/path#ref) + // and reads GIT_USERNAME/GIT_PASSWORD (kanikoGitEnv) for a + // private repo. Otherwise it reads the tar context at + // /workspace/context.tar.gz from the build-context volume. + Env: kanikoGitEnv(useGit, gitAuthSecret), Args: []string{ - "--context=tar:///workspace/context.tar.gz", + kanikoContextArg(useGit, gitContext), "--destination=" + imageTag, "--snapshot-mode=redo", "--cache=false", diff --git a/internal/providers/compute/k8s/client_test.go b/internal/providers/compute/k8s/client_test.go index 5c1cc512..13857285 100644 --- a/internal/providers/compute/k8s/client_test.go +++ b/internal/providers/compute/k8s/client_test.go @@ -23,7 +23,7 @@ func TestKanikoJobHasExplicitResources(t *testing.T) { p := &K8sProvider{clientset: cs} const ns, jobName = "instant-deploy-test", "build-test" - if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", ""); err != nil { + if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", "", "", ""); err != nil { t.Fatalf("createKanikoJob: %v", err) } @@ -81,7 +81,7 @@ func TestKanikoJobUsesInitContainerWhenHTTPURLSet(t *testing.T) { const ns, jobName = "instant-deploy-test", "build-test" httpURL := "http://minio.test:9000/instant-build-contexts/abc/20260511T000000Z.tar.gz?X-Amz-Signature=fake" - if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", httpURL); err != nil { + if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", httpURL, "", ""); err != nil { t.Fatalf("createKanikoJob: %v", err) } @@ -803,7 +803,7 @@ func TestBuildJobHasActiveDeadlineSeconds(t *testing.T) { p := &K8sProvider{clientset: cs} const ns, jobName = "instant-deploy-deadline-test", "build-deadline" - if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", ""); err != nil { + if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", "", "", ""); err != nil { t.Fatalf("createKanikoJob: %v", err) } @@ -843,7 +843,7 @@ func TestBuildJobActiveDeadlineSeconds_TableDriven(t *testing.T) { ns := "instant-deploy-dl-" + tc.name jobName := "build-dl-" + tc.name - if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", tc.httpContextURL); err != nil { + if err := p.createKanikoJob(context.Background(), ns, jobName, "ctx-sec", "auth-sec", "ghcr.io/x/y:latest", tc.httpContextURL, "", ""); err != nil { t.Fatalf("createKanikoJob: %v", err) } diff --git a/internal/providers/compute/k8s/coverage_more_test.go b/internal/providers/compute/k8s/coverage_more_test.go index ff57e063..2ad60e41 100644 --- a/internal/providers/compute/k8s/coverage_more_test.go +++ b/internal/providers/compute/k8s/coverage_more_test.go @@ -817,7 +817,7 @@ func TestCreateKanikoJob_CreateError(t *testing.T) { return true, nil, errors.New("boom") }) p := &K8sProvider{clientset: cs} - if err := p.createKanikoJob(context.Background(), "ns", "j", "ctx", "auth", "img:latest", ""); err == nil { + if err := p.createKanikoJob(context.Background(), "ns", "j", "ctx", "auth", "img:latest", "", "", ""); err == nil { t.Error("expected error") } } diff --git a/internal/providers/compute/k8s/deploy_git_test.go b/internal/providers/compute/k8s/deploy_git_test.go new file mode 100644 index 00000000..b2c04107 --- /dev/null +++ b/internal/providers/compute/k8s/deploy_git_test.go @@ -0,0 +1,311 @@ +package k8s + +// deploy_git_test.go — P3 source=git: Deploy builds a repo via kaniko's git +// context (no tarball upload), with optional private-repo token. All against +// the fake clientset; the build Job is auto-completed by attachJobCompleteReactor. + +import ( + "context" + "errors" + "strings" + "testing" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + clientfake "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" + + "instant.dev/internal/providers/compute" +) + +func gitDeployOpts(appID, gitAuth string) compute.DeployOptions { + return compute.DeployOptions{ + AppID: appID, + Source: "git", + GitURL: "https://github.com/owner/repo", + GitRef: "main", + GitAuth: gitAuth, + Port: 8080, + Tier: "hobby", + TeamID: "11111111-1111-1111-1111-111111111111", + } +} + +// ── pure helpers ───────────────────────────────────────────────────────────── + +func TestGitCloneContext(t *testing.T) { + cases := map[string]struct{ url, ref, want string }{ + "with ref": {"https://github.com/o/r", "main", "git://github.com/o/r#main"}, + "dot-git": {"https://github.com/o/r.git", "", "git://github.com/o/r.git"}, + "http": {"http://h:8080/g/p", "v1", "git://h:8080/g/p#v1"}, + "no scheme": {"github.com/o/r", "", "git://github.com/o/r"}, + } + for name, c := range cases { + if got := gitCloneContext(c.url, c.ref); got != c.want { + t.Errorf("%s: gitCloneContext(%q,%q)=%q want %q", name, c.url, c.ref, got, c.want) + } + } +} + +func TestKanikoContextArg(t *testing.T) { + if got := kanikoContextArg(true, "git://h/p#r"); got != "--context=git://h/p#r" { + t.Errorf("git context arg: %q", got) + } + if got := kanikoContextArg(false, ""); got != "--context=tar:///workspace/context.tar.gz" { + t.Errorf("tar context arg: %q", got) + } +} + +func TestKanikoGitEnv(t *testing.T) { + if kanikoGitEnv(false, "s") != nil { + t.Error("non-git build must have no GIT env") + } + if kanikoGitEnv(true, "") != nil { + t.Error("public git build (no secret) must have no GIT env") + } + env := kanikoGitEnv(true, "git-auth-x") + if len(env) != 2 || env[0].Name != "GIT_USERNAME" || env[1].Name != "GIT_PASSWORD" { + t.Fatalf("private git env shape: %+v", env) + } + if env[1].ValueFrom == nil || env[1].ValueFrom.SecretKeyRef.Name != "git-auth-x" { + t.Errorf("GIT_PASSWORD must come from the git-auth secret: %+v", env[1]) + } +} + +// ── Deploy(git) happy paths ────────────────────────────────────────────────── + +// TestDeploy_GitSource_PublicRepo builds + deploys a public repo: a kaniko Job +// is created with a git:// context and NO build-context volume, no git-auth +// secret, and the runtime Deployment runs the built image. +func TestDeploy_GitSource_PublicRepo(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + attachJobCompleteReactor(cs) + p := &K8sProvider{clientset: cs} + + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitpub", "")); err != nil { + t.Fatalf("Deploy(git, public): %v", err) + } + ns := deployNamespace("gitpub") + jobs, _ := cs.BatchV1().Jobs(ns).List(context.Background(), metav1.ListOptions{}) + if len(jobs.Items) != 1 { + t.Fatalf("expected 1 build Job, got %d", len(jobs.Items)) + } + kaniko := jobs.Items[0].Spec.Template.Spec.Containers[0] + var gotCtx string + for _, a := range kaniko.Args { + if strings.HasPrefix(a, "--context=") { + gotCtx = a + } + } + if gotCtx != "--context=git://github.com/owner/repo#main" { + t.Errorf("kaniko git context = %q", gotCtx) + } + // public repo → no GIT_* env, no build-context volume + for _, e := range kaniko.Env { + if e.Name == "GIT_PASSWORD" { + t.Error("public repo must not set GIT_PASSWORD") + } + } + for _, v := range jobs.Items[0].Spec.Template.Spec.Volumes { + if v.Name == "build-context" { + t.Error("git build must NOT mount a build-context volume") + } + } +} + +// TestDeploy_GitSource_PrivateRepo wires the encrypted token into a git-auth +// Secret consumed via GIT_PASSWORD on the kaniko Job. +func TestDeploy_GitSource_PrivateRepo(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + attachJobCompleteReactor(cs) + p := &K8sProvider{clientset: cs} + + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitpriv", "ghp_secrettoken")); err != nil { + t.Fatalf("Deploy(git, private): %v", err) + } + ns := deployNamespace("gitpriv") + jobs, _ := cs.BatchV1().Jobs(ns).List(context.Background(), metav1.ListOptions{}) + if len(jobs.Items) != 1 { + t.Fatalf("expected 1 build Job, got %d", len(jobs.Items)) + } + kaniko := jobs.Items[0].Spec.Template.Spec.Containers[0] + var hasGitPass bool + for _, e := range kaniko.Env { + if e.Name == "GIT_PASSWORD" && e.ValueFrom != nil && + e.ValueFrom.SecretKeyRef.Name == "git-auth-"+sanitizeName("gitpriv") { + hasGitPass = true + } + } + if !hasGitPass { + t.Errorf("private repo build must set GIT_PASSWORD from the git-auth secret; env=%+v", kaniko.Env) + } +} + +// existing git-auth secret → Update arm. +func TestDeploy_GitSource_PrivateRepo_SecretAlreadyExists(t *testing.T) { + ns := deployNamespace("gitupd") + pre := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "git-auth-" + sanitizeName("gitupd"), Namespace: ns}, + Data: map[string][]byte{"token": []byte("old")}} + cs := clientfake.NewSimpleClientset(platformGHCRPull(), pre) + attachJobCompleteReactor(cs) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitupd", "ghp_new")); err != nil { + t.Fatalf("Deploy(git, existing secret): %v", err) + } +} + +// ── Deploy(git) error arms ─────────────────────────────────────────────────── + +func TestDeploy_GitSource_NamespaceError(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + cs.PrependReactor("create", "namespaces", func(_ clienttesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, errors.New("boom-ns") + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitnserr", "")); err == nil || + !strings.Contains(err.Error(), "ensure namespace") { + t.Fatalf("want ensure-namespace error, got: %v", err) + } +} + +func TestDeploy_GitSource_RegistryAuthError(t *testing.T) { + // No platform ghcr-pull seeded in the instant ns → ensureRegistryAuthInNS fails. + cs := clientfake.NewSimpleClientset() + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitraerr", "")); err == nil || + !strings.Contains(err.Error(), "registry auth") { + t.Fatalf("want registry-auth error, got: %v", err) + } +} + +func TestDeploy_GitSource_GitAuthSecretError(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + cs.PrependReactor("create", "secrets", func(action clienttesting.Action) (bool, k8sruntime.Object, error) { + sec := action.(clienttesting.CreateAction).GetObject().(*corev1.Secret) + if strings.HasPrefix(sec.Name, "git-auth-") { + return true, nil, errors.New("boom-gitsecret") + } + return false, nil, nil + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitsecerr", "ghp_x")); err == nil || + !strings.Contains(err.Error(), "git auth secret") { + t.Fatalf("want git-auth-secret error, got: %v", err) + } +} + +// namespace pre-exists → upgradeNamespaceLabels arm. +func TestDeploy_GitSource_NamespaceAlreadyExists(t *testing.T) { + ns := deployNamespace("gitnsx") + preNS := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}} + cs := clientfake.NewSimpleClientset(platformGHCRPull(), preNS) + attachJobCompleteReactor(cs) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitnsx", "")); err != nil { + t.Fatalf("Deploy(git, pre-existing ns): %v", err) + } +} + +// namespace pre-exists AND the label-upgrade Get fails → upgrade-labels error arm. +func TestDeploy_GitSource_UpgradeNamespaceLabelsError(t *testing.T) { + ns := deployNamespace("gitulerr") + cs := clientfake.NewSimpleClientset(platformGHCRPull(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}) + cs.PrependReactor("get", "namespaces", func(_ clienttesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, errors.New("get blew up") + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitulerr", "")); err == nil || + !strings.Contains(err.Error(), "upgrade namespace labels") { + t.Fatalf("want upgrade-namespace-labels error, got: %v", err) + } +} + +// git-auth secret exists AND Update fails → git-auth-secret-update error arm. +func TestDeploy_GitSource_GitAuthSecretUpdateError(t *testing.T) { + ns := deployNamespace("gitupderr") + pre := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "git-auth-" + sanitizeName("gitupderr"), Namespace: ns}, + Data: map[string][]byte{"token": []byte("old")}} + cs := clientfake.NewSimpleClientset(platformGHCRPull(), pre) + cs.PrependReactor("update", "secrets", func(_ clienttesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, errors.New("boom-update") + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitupderr", "ghp_x")); err == nil || + !strings.Contains(err.Error(), "git auth secret update") { + t.Fatalf("want git-auth-secret-update error, got: %v", err) + } +} + +// private repo + the deferred git-auth Secret cleanup Delete fails (non-NotFound) +// → the warn-log arm in the defer runs (build still succeeds). +func TestDeploy_GitSource_GitAuthSecretCleanupDeleteError(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + attachJobCompleteReactor(cs) + cs.PrependReactor("delete", "secrets", func(_ clienttesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, errors.New("boom-delete") + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitdelerr", "ghp_x")); err != nil { + t.Fatalf("deferred-delete failure must NOT fail the deploy: %v", err) + } +} + +// build NetworkPolicy create failure arm. +func TestDeploy_GitSource_BuildNetworkPolicyError(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + cs.PrependReactor("create", "networkpolicies", func(_ clienttesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, errors.New("boom-np") + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitnperr", "")); err == nil || + !strings.Contains(err.Error(), "buildImageFromGit") { + t.Fatalf("want build-NP error, got: %v", err) + } +} + +// build Job is created but reports Failed → waitForJobComplete error + snapshot. +func TestDeploy_GitSource_BuildJobFailed(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + cs.PrependReactor("create", "jobs", func(action clienttesting.Action) (bool, k8sruntime.Object, error) { + job := action.(clienttesting.CreateAction).GetObject().(*batchv1.Job) + job.Status.Conditions = []batchv1.JobCondition{ + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Message: "git clone failed"}, + } + return false, job, nil + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitfail", "")); err == nil || + !strings.Contains(err.Error(), "kaniko job") { + t.Fatalf("want kaniko-job wait failure, got: %v", err) + } +} + +func TestDeploy_GitSource_KanikoJobError(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + cs.PrependReactor("create", "jobs", func(_ clienttesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, errors.New("boom-job") + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitjoberr", "")); err == nil || + !strings.Contains(err.Error(), "create kaniko job") { + t.Fatalf("want kaniko-job error, got: %v", err) + } +} + +// build succeeds, then setupTenantNamespace fails (ResourceQuota create) → the +// Deploy(git) setup-namespace arm. +func TestDeploy_GitSource_SetupNamespaceErrorAfterBuild(t *testing.T) { + cs := clientfake.NewSimpleClientset(platformGHCRPull()) + attachJobCompleteReactor(cs) + cs.PrependReactor("create", "resourcequotas", func(_ clienttesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, errors.New("boom-quota") + }) + p := &K8sProvider{clientset: cs} + if _, err := p.Deploy(context.Background(), gitDeployOpts("gitsetup", "")); err == nil || + !strings.Contains(err.Error(), "setup namespace") { + t.Fatalf("want setup-namespace error, got: %v", err) + } +} diff --git a/internal/providers/compute/provider.go b/internal/providers/compute/provider.go index d63fb5dd..77c56a84 100644 --- a/internal/providers/compute/provider.go +++ b/internal/providers/compute/provider.go @@ -39,6 +39,14 @@ type DeployOptions struct { // RegistryAuth is an optional docker config JSON for pulling a private // ImageRef. Empty → the namespace's default ghcr-pull secret is used. RegistryAuth string + // GitURL is the clone URL built directly by Kaniko's git context when + // Source=="git" (no tarball upload). Ignored otherwise. + GitURL string + // GitRef is the branch/tag/commit Kaniko checks out; empty → default branch. + GitRef string + // GitAuth is an optional read-only token for a private GitURL, injected as + // Kaniko's GIT_PASSWORD. Empty → the repo is treated as public. + GitAuth string } // AppDeployment represents the live state of a deployed app. diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index 0dfbb8c2..045929e8 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -319,6 +319,10 @@ func runMigrations(t *testing.T, db *sql.DB) { `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'tarball'`, `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS image_ref TEXT NOT NULL DEFAULT ''`, `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS registry_creds_enc TEXT NOT NULL DEFAULT ''`, + // 065_deploy_source_git — P3 git-source deploy columns. + `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS git_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS git_ref TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS git_token_enc TEXT NOT NULL DEFAULT ''`, // teams.default_deployment_ttl_policy — Wave FIX-J team preference. `ALTER TABLE teams ADD COLUMN IF NOT EXISTS default_deployment_ttl_policy TEXT NOT NULL DEFAULT 'auto_24h'`, // 012_audit_log — per-team event stream consumed by the dashboard's From 8d0fdad074af38b71728cf142833b6592bf2956d Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 3 Jun 2026 16:50:07 +0530 Subject: [PATCH 2/3] test(deploy): fix InvalidSource test now that source=git is wired (P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestDeployNew_InvalidSource_400 used source="git" as its "unrecognised" example, but P3 made git a valid (flag-gated) case — so it hit the 501 source_git_disabled arm instead of the 400 invalid_source default, both breaking the assertion and leaving the default branch (deploy.go:919) uncovered. Switch the example to "svn" so it exercises the real default → invalid_source path. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/deploy_source_image_integration_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/handlers/deploy_source_image_integration_test.go b/internal/handlers/deploy_source_image_integration_test.go index d875dd72..53070da3 100644 --- a/internal/handlers/deploy_source_image_integration_test.go +++ b/internal/handlers/deploy_source_image_integration_test.go @@ -82,8 +82,8 @@ func TestDeployNew_SourceImage_FlagOff_501(t *testing.T) { assert.Equal(t, "source_image_disabled", env.Error) } -// TestDeployNew_InvalidSource_400 — an unrecognised source (neither tarball -// nor image, e.g. "git" which is not yet wired) is a clean 400. +// TestDeployNew_InvalidSource_400 — an unrecognised source (none of tarball, +// image, or git — e.g. "svn") is a clean 400. (Was "git" before P3 wired it.) func TestDeployNew_InvalidSource_400(t *testing.T) { daDeployNeedsDB(t) db, cleanDB := testhelpers.SetupTestDB(t) @@ -98,7 +98,7 @@ func TestDeployNew_InvalidSource_400(t *testing.T) { body, ct := buildImageDeployForm(t, map[string]string{ "name": "bad-source-app", - "source": "git", + "source": "svn", // not tarball/image/git → default → invalid_source }) resp := postDeploy(t, app, body, ct, jwt) defer resp.Body.Close() From b7cdaff2bcc83ab7e5a9c32188522eab77109d98 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 3 Jun 2026 17:05:18 +0530 Subject: [PATCH 3/3] =?UTF-8?q?test(deploy):=20widen=20async-deploy=20poll?= =?UTF-8?q?=20deadline=205s=E2=86=9230s=20(flake=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestDeployNew_SourceImage_FlagOn_Accepted flaked on the 5s poll for the async runDeploy goroutine to stamp the row healthy — the goroutine's DB writes can run past 5s under `-race -p 1` with the full suite loaded. Bump both the image and git happy-path polls to a 30s ceiling (still early-breaks the instant the provider id appears, so normal runs are unaffected). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/deploy_source_git_integration_test.go | 4 +++- internal/handlers/deploy_source_image_integration_test.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/handlers/deploy_source_git_integration_test.go b/internal/handlers/deploy_source_git_integration_test.go index 49da09e9..c50a4d91 100644 --- a/internal/handlers/deploy_source_git_integration_test.go +++ b/internal/handlers/deploy_source_git_integration_test.go @@ -163,7 +163,9 @@ func TestDeployNew_SourceGit_FlagOn_Accepted(t *testing.T) { // runDeploy is async — poll the row until the noop provider stamps it // healthy with a provider id (proves applyGitSourceOpts → compute.Deploy ran). - deadline := time.Now().Add(5 * time.Second) + // Generous deadline: the async runDeploy goroutine does DB writes that can + // be slow under `-race -p 1` with the full suite loaded. + deadline := time.Now().Add(30 * time.Second) var status, providerID string var gitURLCol sql.NullString for time.Now().Before(deadline) { diff --git a/internal/handlers/deploy_source_image_integration_test.go b/internal/handlers/deploy_source_image_integration_test.go index 53070da3..b37e2427 100644 --- a/internal/handlers/deploy_source_image_integration_test.go +++ b/internal/handlers/deploy_source_image_integration_test.go @@ -230,7 +230,9 @@ func TestDeployNew_SourceImage_FlagOn_Accepted(t *testing.T) { // runDeploy is async — poll the row until the (noop) compute provider has // stamped it healthy with a provider id. This deterministically waits for // the applyImageSourceOpts → compute.Deploy path to execute. - deadline := time.Now().Add(5 * time.Second) + // Generous deadline: the async runDeploy goroutine does DB writes that can + // be slow under `-race -p 1` with the full suite loaded (was 5s → flaked). + deadline := time.Now().Add(30 * time.Second) var status, providerID string var imageRef sql.NullString for time.Now().Before(deadline) {