Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ linters:
linters:
- errcheck
- gocyclo
# SA5011 (possible nil pointer dereference) fires in test files on the
# idiomatic `got := f(); if got == nil { t.Fatalf/Errorf(...) }; got.X`
# pattern — a nil deref in a test panics and fails the test loudly, so
# this is benign test noise, not a production-safety signal. Suppressed
# by-check (SA5011) in tests only; production SA5011 still fails the build.
# Scoped here rather than disabling staticcheck for tests wholesale.
- path: _test\.go
linters:
- staticcheck
text: "SA5011"
# SA1019 (deprecated madmin.New / SetPolicy) in the MinIO provider. MinIO is a
# local-dev-only object-store backend; the suggested replacements
# (NewWithOptions / AttachPolicy) have different call shapes and semantics, so a
Expand Down
16 changes: 16 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ type Config struct {
// and fail token validation (deterministic disable for rollback).
FamilyBindingsEnabled bool

// DeploySourceImageEnabled gates the P2 multi-source "source=image" path
// (deploy a prebuilt image instead of uploading source). Default FALSE:
// the skip-Kaniko compute branch changes the live deploy path and can't be
// validated in CI (no real cluster), so it stays off until the operator
// flips DEPLOY_SOURCE_IMAGE_ENABLED=true after a canary. Off → /deploy/new
// rejects source=image with 501; tarball deploys are unaffected.
DeploySourceImageEnabled 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
Expand Down Expand Up @@ -421,6 +429,14 @@ func Load() *Config {
cfg.FamilyBindingsEnabled = true
}

// DEPLOY_SOURCE_IMAGE_ENABLED: default FALSE (off until operator canary).
switch strings.ToLower(strings.TrimSpace(os.Getenv("DEPLOY_SOURCE_IMAGE_ENABLED"))) {
case "true", "1", "yes":
cfg.DeploySourceImageEnabled = true
default:
cfg.DeploySourceImageEnabled = false
}

if len(cfg.JWTSecret) < 32 {
panic("JWT_SECRET must be at least 32 bytes")
}
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +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",
"BREVO_WEBHOOK_SECRET", "SES_SNS_SUBSCRIPTION_ARN",
"SENDGRID_WEBHOOK_PUBLIC_KEY",
"WORKER_INTERNAL_JWT_SECRET", "ADMIN_PATH_PREFIX",
Expand Down Expand Up @@ -241,6 +242,9 @@ func TestLoad_HappyPath_AppliesDefaults(t *testing.T) {
if !cfg.FamilyBindingsEnabled {
t.Error("FamilyBindingsEnabled default must be true")
}
if cfg.DeploySourceImageEnabled {
t.Error("DeploySourceImageEnabled 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)
Expand Down Expand Up @@ -330,6 +334,22 @@ func TestLoad_FamilyBindingsDisabled(t *testing.T) {
}
}

func TestLoad_DeploySourceImageEnabled(t *testing.T) {
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
applyBaselineEnv(t, map[string]string{"DEPLOY_SOURCE_IMAGE_ENABLED": val})
if !Load().DeploySourceImageEnabled {
t.Errorf("DEPLOY_SOURCE_IMAGE_ENABLED=%q should enable", val)
}
}
// Unrecognized / off values → stays disabled.
for _, val := range []string{"false", "0", "no", "maybe", ""} {
applyBaselineEnv(t, map[string]string{"DEPLOY_SOURCE_IMAGE_ENABLED": val})
if Load().DeploySourceImageEnabled {
t.Errorf("DEPLOY_SOURCE_IMAGE_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 {
Expand Down
34 changes: 34 additions & 0 deletions internal/db/migrations/064_deploy_source_image.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- 064_deploy_source_image.sql — multi-source deploys, P2 (source=image / BYO image).
--
-- WHY: P1 (#220) capped /deploy/new uploads at 10 MB and nudges large projects
-- to "deploy a prebuilt image instead of uploading source". This migration adds
-- the columns that let a deployment record where its image came from, so a
-- caller can skip the tarball+Kaniko build entirely and have the platform
-- deploy a prebuilt image (e.g. one their GitHub CI already pushed to GHCR).
--
-- All columns are ADDITIVE with safe defaults, so every existing row + every
-- existing tarball deploy keeps working unchanged:
--
-- source — 'tarball' (default, the only mode before P2) | 'image'.
-- Future P3 adds 'git'. CHECK keeps it to known modes.
-- image_ref — fully-qualified registry ref (host/path[:tag][@digest])
-- for source='image'; '' for tarball deploys.
-- registry_creds_enc — AES-256-GCM ciphertext of the optional pull-only
-- registry credential JSON (whole-object encryption,
-- same posture as notify_webhook_secret). '' when the
-- image is public / uses the platform's ghcr-pull secret.
-- NEVER returned to the client (deploymentToMap emits
-- only registry_creds_set: bool).

ALTER TABLE deployments
ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'tarball',
ADD COLUMN IF NOT EXISTS image_ref TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS registry_creds_enc TEXT NOT NULL DEFAULT '';

-- Constrain source to the modes the code understands. 'git' is reserved for P3
-- so adding it here now is forward-safe and avoids a follow-up migration.
ALTER TABLE deployments
DROP CONSTRAINT IF EXISTS deployments_source_check;
ALTER TABLE deployments
ADD CONSTRAINT deployments_source_check
CHECK (source IN ('tarball', 'image', 'git'));
184 changes: 162 additions & 22 deletions internal/handlers/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"instant.dev/internal/config"
"instant.dev/internal/crypto"
"instant.dev/internal/email"
"instant.dev/internal/metrics"
"instant.dev/internal/middleware"
Expand Down Expand Up @@ -66,6 +67,87 @@ const maxTarballBytes = 10 << 20
// upload or deploy a prebuilt image. Shared by /deploy/new and its
// redeploy=true branch so the cap can never drift between the two. Returns
// nil when the upload is within the cap.
// validateImageRef checks a source=image reference is fully qualified
// (host/path[:tag][@digest]). We REJECT bare names ("nginx", "owner/repo")
// that Docker would silently resolve against Docker Hub — a deploy must name
// its registry explicitly so there's no ambiguity about what gets pulled and
// run on shared nodes. Returns the trimmed, validated ref.
func validateImageRef(ref string) (string, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", fmt.Errorf("image_ref is required for source=image (e.g. ghcr.io/owner/app:tag)")
}
if len(ref) > 512 {
return "", fmt.Errorf("image_ref is too long")
}
for _, r := range ref {
if r <= ' ' || r > '~' {
return "", fmt.Errorf("image_ref contains invalid characters")
}
}
slash := strings.IndexByte(ref, '/')
if slash < 0 {
return "", fmt.Errorf("image_ref must include a registry host (e.g. ghcr.io/owner/app:tag), not a bare name")
}
host := ref[:slash]
// A registry host has a dot, a port, or is localhost. "owner/repo" (Docker
// Hub implicit namespace) is rejected — name docker.io explicitly.
if host != "localhost" && !strings.ContainsAny(host, ".:") {
return "", fmt.Errorf("image_ref must start with a registry host (e.g. ghcr.io/...), got %q", host)
}
return ref, nil
}

// applyImageSourceOpts populates DeployOptions for a source=image deployment
// from the persisted row: sets Source/ImageRef, clears Tarball, and decrypts
// the optional BYO registry creds into RegistryAuth. A decrypt failure (or bad
// key) is logged and falls back to the platform pull secret — public/GHCR
// images still deploy. No-op for non-image sources.
func applyImageSourceOpts(opts *compute.DeployOptions, d *models.Deployment, aesKeyHex string) {
if d.Source != "image" {
return
}
opts.Source = "image"
opts.ImageRef = d.ImageRef
opts.Tarball = nil
if d.RegistryCredsEnc == "" {
return
}
key, kerr := crypto.ParseAESKey(aesKeyHex)
if kerr != nil {
slog.Error("deploy.image.aes_key_invalid", "app_id", d.AppID, "error", kerr)
return
}
plain, derr := crypto.Decrypt(key, d.RegistryCredsEnc)
if derr != nil {
slog.Error("deploy.run_deploy.registry_creds_decrypt_failed", "app_id", d.AppID, "error", derr)
return
}
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) {
key, err := crypto.ParseAESKey(aesKeyHex)
if err != nil {
return "", err
}
return crypto.Encrypt(key, plaintext)
}

// deploymentSourceOrDefault normalises an empty source (legacy in-memory rows)
// to the 'tarball' default the migration applies at the DB layer.
func deploymentSourceOrDefault(s string) string {
if s == "" {
return "tarball"
}
return s
}

func enforceTarballCap(c *fiber.Ctx, fh *multipart.FileHeader) error {
if fh.Size > maxTarballBytes {
return respondError(c, fiber.StatusRequestEntityTooLarge, "tarball_too_large",
Expand Down Expand Up @@ -307,6 +389,14 @@ func deploymentToMapWithDB(d *models.Deployment, db *sql.DB) fiber.Map {
// secret is NEVER returned — only its lifecycle metadata.
"notify_webhook": d.NotifyWebhook,
"notify_state": d.NotifyState,
// Multi-source deploys (migration 064). source defaults to 'tarball'.
// image_ref is echoed (caller-supplied, no secret); registry_creds is
// NEVER returned — only registry_creds_set lifecycle metadata.
"source": deploymentSourceOrDefault(d.Source),
}
if d.Source == "image" {
m["image_ref"] = d.ImageRef
m["registry_creds_set"] = d.RegistryCredsEnc != ""
}
if d.NotifyWebhook != "" {
m["notify_attempts"] = d.NotifyAttempts
Expand Down Expand Up @@ -528,6 +618,9 @@ 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).
applyImageSourceOpts(&opts, d, h.cfg.AESKey)
result, err := h.compute.Deploy(ctx, opts)
if err != nil {
slog.Error("deploy.run_deploy.failed",
Expand Down Expand Up @@ -599,30 +692,74 @@ func (h *DeployHandler) New(c *fiber.Ctx) error {
"Request must be multipart/form-data (max 50 MB)")
}

// tarball field.
tarballs := form.File["tarball"]
if len(tarballs) == 0 {
return respondError(c, fiber.StatusBadRequest, "missing_tarball",
"Multipart field 'tarball' is required")
// Source mode (migration 064): "tarball" (default — build the uploaded
// source with Kaniko) or "image" (BYO — deploy a prebuilt image_ref
// directly, no upload/build). source=image is naturally Hobby+ (the
// per-tier deploy cap below gates anon/free, which have deployments_apps=0).
source := "tarball"
if vals := form.Value["source"]; len(vals) > 0 && strings.TrimSpace(vals[0]) != "" {
source = strings.ToLower(strings.TrimSpace(vals[0]))
}
fh := tarballs[0]
if err := enforceTarballCap(c, fh); err != nil {
return err
}
f, err := openMultipartFile(fh)
if err != nil {
return respondError(c, fiber.StatusBadRequest, "tarball_open_failed",
"Failed to read tarball")
}
defer func() { _ = f.Close() }()

// P0-3: io.ReadAll, not a single f.Read — a lone Read short-reads on
// disk-spilled multipart files (n is discarded), truncating large tarballs.
// Mirrors how stack.go reads its tarball field.
tarball, err := io.ReadAll(f)
if err != nil {
return respondError(c, fiber.StatusBadRequest, "tarball_read_failed",
"Failed to read tarball bytes")
var tarball []byte
var imageRef, registryCredsEnc string

switch source {
case "image":
// Flag-gated (P2): the skip-Kaniko deploy path is off until an operator
// enables it post-canary (DEPLOY_SOURCE_IMAGE_ENABLED=true). Until then
// reject cleanly so tarball deploys are never affected by unproven code.
if !h.cfg.DeploySourceImageEnabled {
return respondError(c, fiber.StatusNotImplemented, "source_image_disabled",
"Deploying from a prebuilt image (source=image) is rolling out and not yet enabled. Upload source (tarball ≤10MB) for now.")
}
raw := ""
if vals := form.Value["image_ref"]; len(vals) > 0 {
raw = strings.TrimSpace(vals[0])
}
validRef, refErr := validateImageRef(raw)
if refErr != nil {
return respondError(c, fiber.StatusBadRequest, "invalid_image_ref", refErr.Error())
}
imageRef = validRef
// Optional BYO registry creds for a PRIVATE image: a complete docker
// 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]))
if encErr != nil {
return respondError(c, fiber.StatusServiceUnavailable, "encrypt_failed",
"Could not secure registry credentials")
}
registryCredsEnc = enc
}
case "tarball":
tarballs := form.File["tarball"]
if len(tarballs) == 0 {
return respondError(c, fiber.StatusBadRequest, "missing_tarball",
"Multipart field 'tarball' is required (or use source=image with image_ref)")
}
fh := tarballs[0]
if err := enforceTarballCap(c, fh); err != nil {
return err
}
f, ferr := openMultipartFile(fh)
if ferr != nil {
return respondError(c, fiber.StatusBadRequest, "tarball_open_failed",
"Failed to read tarball")
}
defer func() { _ = f.Close() }()
// P0-3: io.ReadAll, not a single f.Read — a lone Read short-reads on
// disk-spilled multipart files (n is discarded), truncating large tarballs.
b, rerr := io.ReadAll(f)
if rerr != nil {
return respondError(c, fiber.StatusBadRequest, "tarball_read_failed",
"Failed to read tarball bytes")
}
tarball = b
default:
return respondError(c, fiber.StatusBadRequest, "invalid_source",
"Field 'source' must be 'tarball' (default) or 'image'")
}

// Required name field — the human-readable deployment label.
Expand Down Expand Up @@ -951,6 +1088,9 @@ func (h *DeployHandler) New(c *fiber.Ctx) error {
NotifyWebhook: notifyURL,
NotifyWebhookSecret: notifySecret,
TTLPolicy: ttlPolicy,
Source: source,
ImageRef: imageRef,
RegistryCredsEnc: registryCredsEnc,
})
if errors.Is(err, models.ErrDeploymentCapReached) {
// Over the per-tier cap — surfaced atomically inside the
Expand Down
Loading
Loading