From e67fa1cd1887ea541bca23fedc6f2b006d724d6f Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 2 Jul 2026 23:35:18 +0200 Subject: [PATCH 1/4] fix: database auth --- internal/localmode/info_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/localmode/info_test.go b/internal/localmode/info_test.go index 71f0e5e..f4de60c 100644 --- a/internal/localmode/info_test.go +++ b/internal/localmode/info_test.go @@ -30,7 +30,7 @@ func TestFetchInfoRunsDockerExecLocalInfo(t *testing.T) { "default_database_name":"app", "default_database_region":"local", "default_database_postgres_version":"16", - "database_url":"postgres://volcano:volcano@localhost:8002/app?sslmode=disable&application_name=volcano_full_access:app", + "database_url":"postgres://volcano_client_22222222-2222-2222-2222-222222222222:vpg_local_secret@localhost:8002/app?sslmode=disable&application_name=volcano_full_access", "redis_url":"redis://localhost:6379", "jwt_secret":"server-owned-jwt-secret", "encryption_key":"server-owned-encryption-key", @@ -54,7 +54,7 @@ func TestFetchInfoRunsDockerExecLocalInfo(t *testing.T) { assert.Equal(t, "app", info.DefaultDatabaseName) assert.Equal(t, "local", info.DefaultDatabaseRegion) assert.Equal(t, "16", info.DefaultDatabasePostgresVersion) - assert.Equal(t, "postgres://volcano:volcano@localhost:8002/app?sslmode=disable&application_name=volcano_full_access:app", info.DatabaseURL) + assert.Equal(t, "postgres://volcano_client_22222222-2222-2222-2222-222222222222:vpg_local_secret@localhost:8002/app?sslmode=disable&application_name=volcano_full_access", info.DatabaseURL) assert.Equal(t, "redis://localhost:6379", info.RedisURL) assert.Equal(t, "server-owned-jwt-secret", info.JWTSecret) assert.Equal(t, "server-owned-encryption-key", info.EncryptionKey) From 2b11e93f3e6fccc797469b9501158e12ffa1c0ea Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Fri, 3 Jul 2026 01:00:29 +0200 Subject: [PATCH 2/4] fix: test --- .../assets/docker-compose.template.yml | 91 +++++++++++-------- internal/localmode/compose_test.go | 14 ++- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/internal/localmode/assets/docker-compose.template.yml b/internal/localmode/assets/docker-compose.template.yml index d7f7a11..5e40a54 100644 --- a/internal/localmode/assets/docker-compose.template.yml +++ b/internal/localmode/assets/docker-compose.template.yml @@ -82,9 +82,9 @@ services: AWS_REGION: us-east-1 AWS_ACCESS_KEY_ID: local AWS_SECRET_ACCESS_KEY: local - REDIS_TIMEOUT: "60" - USAGE_SYNC_INTERVAL: "30" - USAGE_SYNC_LOCK_TTL: "30" + REDIS_TIMEOUT: "60s" + USAGE_SYNC_INTERVAL: "30s" + USAGE_SYNC_LOCK_TTL: "30s" SOURCE_ARCHIVE_SIZE_LIMIT_MB: "256" LAMBDA_TARGET_CONTAINER_SIZE_LIMIT_MB: "4096" PORT: "8000" @@ -104,48 +104,65 @@ services: FREE_FUNCTION_TIMEOUT: "30" FREE_FUNCTION_MEMORY: "128" FREE_FUNCTION_DISK: "512" - PRO_FUNCTION_TIMEOUT: "300" - PRO_FUNCTION_MEMORY: "1024" - PRO_FUNCTION_DISK: "2048" + FREE_FUNCTION_RATELIMIT: "10" + FREE_FUNCTION_ALL_RATELIMIT: "60" + FREE_FUNC_INVOCATIONS_PER_MONTH: "100000" + FREE_BUILD_TIMEOUT_MINUTES: "30" + # Monthly build-minutes cap (0 = unlimited). Local-mode keeps builds + # unconstrained so dev iteration is never blocked. + FREE_BUILD_MAX_MINUTES: "0" + PRO_BUILD_MAX_MINUTES: "0" + # Runtime log retention (TTL) and max search lookback in days. + FREE_LOG_RETENTION_DAYS: "1" + PRO_LOG_RETENTION_DAYS: "30" + PRO_FUNCTION_TIMEOUT: "180" + PRO_FUNCTION_MEMORY: "256" + PRO_FUNCTION_DISK: "1024" + PRO_FUNCTION_RATELIMIT: "0" + PRO_FUNCTION_ALL_RATELIMIT: "0" + PRO_FUNC_INVOCATIONS_PER_MONTH: "0" + PRO_BUILD_TIMEOUT_MINUTES: "60" + FREE_IMAGE_OPTIMIZER_TIMEOUT: "90" + PRO_IMAGE_OPTIMIZER_TIMEOUT: "90" + FREE_IMAGE_OPTIMIZER_MEMORY: "1024" + PRO_IMAGE_OPTIMIZER_MEMORY: "1024" + FREE_IMAGE_OPTIMIZER_DISK: "1024" + PRO_IMAGE_OPTIMIZER_DISK: "1024" # Custom-domain plan limits (cloud separates FREE/PRO; meaningless locally) FREE_FRONTEND_CUSTOM_DOMAINS: "10" PRO_FRONTEND_CUSTOM_DOMAINS: "10" - # Function scheduler limits (cloud: FREE=0, PRO=5; local-mode keeps both - # permissive so signup users on the default FREE plan can still test) - FREE_SCHEDULER_COUNT: "10" - PRO_SCHEDULER_COUNT: "10" + # Function scheduler limits. Sentinel convention: -1 = disabled, 0 = + # unlimited, N = cap. Cloud sets FREE=-1 (disabled), PRO=5; local-mode + # keeps cloud parity for plan-limit testing. + FREE_SCHEDULER_COUNT: "-1" + PRO_SCHEDULER_COUNT: "5" + # Database count caps. Sentinel convention: 0 = unlimited, N = cap. Cloud + # sets FREE=1, PRO=0 (unlimited); local-mode keeps both unlimited so signup + # users on the default FREE plan can create as many databases as they need. + FREE_DATABASE_CAP: "0" + PRO_DATABASE_CAP: "0" + # Database storage caps in MB. 0 = unlimited. Cloud sets FREE=1024 (1 GB), + # PRO=0; local-mode keeps both unlimited so dev databases can grow freely. + FREE_DATABASE_STORAGE_LIMIT_MB: "0" + PRO_DATABASE_STORAGE_LIMIT_MB: "0" + FREE_STORAGE_BUCKETS_PER_PROJECT: "20" + PRO_STORAGE_BUCKETS_PER_PROJECT: "0" # Deployable regions used by project region resolution (`all_regions=true` # projects default to this list). Cloud passes the real multi-region set; # locally we surface a representative subset so feature pickers and # scheduler validation have something to work with. AWS_REGIONS: "us-east-1,eu-west-1,ap-southeast-1" - # Stripe Billing (optional) - STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} - STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} - STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-} - STRIPE_PRICE_ID_PRO_YEARLY: ${STRIPE_PRICE_ID_PRO_YEARLY:-} - STRIPE_PRICE_ID_ADDON_BUILDER_CREDITS_MONTHLY: ${STRIPE_PRICE_ID_ADDON_BUILDER_CREDITS_MONTHLY:-} - STRIPE_PRICE_ID_ADDON_BUILDER_CREDITS_YEARLY: ${STRIPE_PRICE_ID_ADDON_BUILDER_CREDITS_YEARLY:-} - STRIPE_PRICE_ID_ADDON_FUNC_INVOCATIONS_MONTHLY: ${STRIPE_PRICE_ID_ADDON_FUNC_INVOCATIONS_MONTHLY:-} - STRIPE_PRICE_ID_ADDON_FUNC_INVOCATIONS_YEARLY: ${STRIPE_PRICE_ID_ADDON_FUNC_INVOCATIONS_YEARLY:-} - STRIPE_PRICE_ID_ADDON_STORAGE_REQUESTS_MONTHLY: ${STRIPE_PRICE_ID_ADDON_STORAGE_REQUESTS_MONTHLY:-} - STRIPE_PRICE_ID_ADDON_STORAGE_REQUESTS_YEARLY: ${STRIPE_PRICE_ID_ADDON_STORAGE_REQUESTS_YEARLY:-} - STRIPE_PRICE_ID_ADDON_AUTH_REQUESTS_MONTHLY: ${STRIPE_PRICE_ID_ADDON_AUTH_REQUESTS_MONTHLY:-} - STRIPE_PRICE_ID_ADDON_AUTH_REQUESTS_YEARLY: ${STRIPE_PRICE_ID_ADDON_AUTH_REQUESTS_YEARLY:-} - STRIPE_PRICE_ID_ADDON_DATABASE_REQUESTS_MONTHLY: ${STRIPE_PRICE_ID_ADDON_DATABASE_REQUESTS_MONTHLY:-} - STRIPE_PRICE_ID_ADDON_DATABASE_REQUESTS_YEARLY: ${STRIPE_PRICE_ID_ADDON_DATABASE_REQUESTS_YEARLY:-} - STRIPE_PRICE_ID_ADDON_REALTIME_MESSAGES_MONTHLY: ${STRIPE_PRICE_ID_ADDON_REALTIME_MESSAGES_MONTHLY:-} - STRIPE_PRICE_ID_ADDON_REALTIME_MESSAGES_YEARLY: ${STRIPE_PRICE_ID_ADDON_REALTIME_MESSAGES_YEARLY:-} - STRIPE_PRICE_ID_OVERAGE_FUNC_INVOCATIONS_MONTHLY: ${STRIPE_PRICE_ID_OVERAGE_FUNC_INVOCATIONS_MONTHLY:-} - STRIPE_PRICE_ID_OVERAGE_FUNC_INVOCATIONS_YEARLY: ${STRIPE_PRICE_ID_OVERAGE_FUNC_INVOCATIONS_YEARLY:-} - STRIPE_PRICE_ID_OVERAGE_STORAGE_REQUESTS_MONTHLY: ${STRIPE_PRICE_ID_OVERAGE_STORAGE_REQUESTS_MONTHLY:-} - STRIPE_PRICE_ID_OVERAGE_STORAGE_REQUESTS_YEARLY: ${STRIPE_PRICE_ID_OVERAGE_STORAGE_REQUESTS_YEARLY:-} - STRIPE_PRICE_ID_OVERAGE_AUTH_REQUESTS_MONTHLY: ${STRIPE_PRICE_ID_OVERAGE_AUTH_REQUESTS_MONTHLY:-} - STRIPE_PRICE_ID_OVERAGE_AUTH_REQUESTS_YEARLY: ${STRIPE_PRICE_ID_OVERAGE_AUTH_REQUESTS_YEARLY:-} - STRIPE_PRICE_ID_OVERAGE_DATABASE_REQUESTS_MONTHLY: ${STRIPE_PRICE_ID_OVERAGE_DATABASE_REQUESTS_MONTHLY:-} - STRIPE_PRICE_ID_OVERAGE_DATABASE_REQUESTS_YEARLY: ${STRIPE_PRICE_ID_OVERAGE_DATABASE_REQUESTS_YEARLY:-} - STRIPE_PRICE_ID_OVERAGE_REALTIME_MESSAGES_MONTHLY: ${STRIPE_PRICE_ID_OVERAGE_REALTIME_MESSAGES_MONTHLY:-} - STRIPE_PRICE_ID_OVERAGE_REALTIME_MESSAGES_YEARLY: ${STRIPE_PRICE_ID_OVERAGE_REALTIME_MESSAGES_YEARLY:-} + # First-party bootstrap (optional). ANON_KEY_SECRET is sourced from + # .env.local and must match the secret VOLCANO_FIRST_PARTY_ANON_KEY was + # signed with, since the server validates that key during bootstrap. + ANON_KEY_SECRET: ${ANON_KEY_SECRET:-} + VOLCANO_FIRST_PARTY_USER_ID: ${VOLCANO_FIRST_PARTY_USER_ID:-} + VOLCANO_FIRST_PARTY_USER_DISPLAY_NAME: ${VOLCANO_FIRST_PARTY_USER_DISPLAY_NAME:-} + VOLCANO_FIRST_PARTY_USER_TOKEN: ${VOLCANO_FIRST_PARTY_USER_TOKEN:-} + VOLCANO_FIRST_PARTY_PROJECT_ID: ${VOLCANO_FIRST_PARTY_PROJECT_ID:-} + VOLCANO_FIRST_PARTY_PROJECT_NAME: ${VOLCANO_FIRST_PARTY_PROJECT_NAME:-} + VOLCANO_FIRST_PARTY_ANON_KEY: ${VOLCANO_FIRST_PARTY_ANON_KEY:-} + VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID: ${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID:-} volumes: - volcano-storage:/app/local-storage ports: diff --git a/internal/localmode/compose_test.go b/internal/localmode/compose_test.go index bf3e925..0dbebc0 100644 --- a/internal/localmode/compose_test.go +++ b/internal/localmode/compose_test.go @@ -95,10 +95,22 @@ func TestComposeEnvironmentDefaultsVolcanoImage(t *testing.T) { func TestDockerComposeTemplateLeavesServerOwnedLocalSecretsUnset(t *testing.T) { template := string(dockerComposeTemplate) + // The local server generates and owns these secrets, so they must never + // appear in the template at all. assert.NotContains(t, template, "JWT_SECRET:") assert.NotContains(t, template, "ENCRYPTION_KEY:") - assert.NotContains(t, template, "ANON_KEY_SECRET:") assert.NotContains(t, template, "SERVICE_KEY_SECRET:") + + // ANON_KEY_SECRET is an optional first-party-bootstrap passthrough. It may + // appear only as an empty ${ANON_KEY_SECRET:-} default (which leaves it unset + // so the server still owns it), never as a hardcoded secret value. + for _, line := range strings.Split(template, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "ANON_KEY_SECRET:") { + assert.Equal(t, "ANON_KEY_SECRET: ${ANON_KEY_SECRET:-}", trimmed, + "ANON_KEY_SECRET must only be an empty passthrough, not a hardcoded secret") + } + } } func TestDockerComposeTemplateExposesLocalFrontendProxy(t *testing.T) { From 9822f303a800d9a59d5da5b85a145824c3b9ae60 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Fri, 3 Jul 2026 01:16:09 +0200 Subject: [PATCH 3/4] ci: lint --- internal/localmode/compose_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/localmode/compose_test.go b/internal/localmode/compose_test.go index 0dbebc0..d4e94a4 100644 --- a/internal/localmode/compose_test.go +++ b/internal/localmode/compose_test.go @@ -104,7 +104,7 @@ func TestDockerComposeTemplateLeavesServerOwnedLocalSecretsUnset(t *testing.T) { // ANON_KEY_SECRET is an optional first-party-bootstrap passthrough. It may // appear only as an empty ${ANON_KEY_SECRET:-} default (which leaves it unset // so the server still owns it), never as a hardcoded secret value. - for _, line := range strings.Split(template, "\n") { + for line := range strings.SplitSeq(template, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "ANON_KEY_SECRET:") { assert.Equal(t, "ANON_KEY_SECRET: ${ANON_KEY_SECRET:-}", trimmed, From 92c5d6cb3da540f53dd7d170346c8e3e9072fbc8 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Sat, 4 Jul 2026 10:17:23 +0200 Subject: [PATCH 4/4] fix: addressing review --- internal/localmode/compose_test.go | 53 ++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/internal/localmode/compose_test.go b/internal/localmode/compose_test.go index d4e94a4..a9b92ae 100644 --- a/internal/localmode/compose_test.go +++ b/internal/localmode/compose_test.go @@ -2,8 +2,10 @@ package localmode import ( "os" + "regexp" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -124,8 +126,9 @@ func TestDockerComposeTemplateExposesLocalFrontendProxy(t *testing.T) { func TestDockerComposeTemplateSetsPlanLimitsAndRegions(t *testing.T) { template := string(dockerComposeTemplate) - // Plan-limit env vars are required >0 for function/frontend deploy parity; - // guard against an upstream regen that drops or renames them. + // Plan-limit env vars must survive an upstream template regen; guard against + // one that drops or renames them. Presence only -- some are legitimately "0" + // (unlimited/disabled), so this does not assert a value. for _, key := range []string{ "FREE_FUNCTION_TIMEOUT", "FREE_FUNCTION_MEMORY", @@ -137,12 +140,58 @@ func TestDockerComposeTemplateSetsPlanLimitsAndRegions(t *testing.T) { "PRO_FRONTEND_CUSTOM_DOMAINS", "FREE_SCHEDULER_COUNT", "PRO_SCHEDULER_COUNT", + "FREE_BUILD_MAX_MINUTES", + "PRO_BUILD_MAX_MINUTES", + "FREE_LOG_RETENTION_DAYS", + "PRO_LOG_RETENTION_DAYS", + "FREE_IMAGE_OPTIMIZER_TIMEOUT", + "PRO_IMAGE_OPTIMIZER_TIMEOUT", + "FREE_IMAGE_OPTIMIZER_MEMORY", + "PRO_IMAGE_OPTIMIZER_MEMORY", + "FREE_IMAGE_OPTIMIZER_DISK", + "PRO_IMAGE_OPTIMIZER_DISK", + "FREE_DATABASE_CAP", + "PRO_DATABASE_CAP", + "FREE_DATABASE_STORAGE_LIMIT_MB", + "PRO_DATABASE_STORAGE_LIMIT_MB", + "FREE_STORAGE_BUCKETS_PER_PROJECT", + "PRO_STORAGE_BUCKETS_PER_PROJECT", "AWS_REGIONS", } { assert.Contains(t, template, key+":", "compose template missing required env var %s", key) } } +// TestDockerComposeTemplateUsesDurationStringsForTimingVars guards the +// duration-string contract with the server. The server parses these as Go +// time.Duration (RedisTimeout/UsageSyncInterval/UsageSyncLockTTL in +// cmd/server/internal/config), so a bare integer like "60" fails +// time.ParseDuration and the container refuses to start. Guard against an +// upstream regen that drops the duration suffix. +func TestDockerComposeTemplateUsesDurationStringsForTimingVars(t *testing.T) { + template := string(dockerComposeTemplate) + + for _, key := range []string{ + "REDIS_TIMEOUT", + "USAGE_SYNC_INTERVAL", + "USAGE_SYNC_LOCK_TTL", + } { + value := composeTemplateEnvValue(t, template, key) + _, err := time.ParseDuration(value) + assert.NoErrorf(t, err, "%s must be a Go duration string (got %q); the server rejects bare integers", key, value) + } +} + +// composeTemplateEnvValue extracts the (optionally quoted) value assigned to key +// in the raw compose template, e.g. `REDIS_TIMEOUT: "60s"` -> "60s". +func composeTemplateEnvValue(t *testing.T, template, key string) string { + t.Helper() + re := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `:\s*"?([^"\n]+)"?\s*$`) + match := re.FindStringSubmatch(template) + require.Len(t, match, 2, "compose template missing env var %s", key) + return strings.TrimSpace(match[1]) +} + func envValues(env []string, key string) []string { prefix := key + "=" values := []string{}