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
176 changes: 95 additions & 81 deletions internal/config/config.go

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions internal/db/migrations/065_deploy_source_git.sql
Original file line number Diff line number Diff line change
@@ -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 '';
180 changes: 170 additions & 10 deletions internal/handlers/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"io"
"log/slog"
"mime/multipart"
"net"
"net/url"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != ""
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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":
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading