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
29 changes: 29 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ type Config struct {
// Off → /deploy/new rejects source=git with 501; tarball/image unaffected.
DeploySourceGitEnabled bool

// GitHub App (P4) — install-once push-to-deploy + short-lived installation
// tokens for private-repo clones. Distinct from the GitHub OAuth *login* app
// above (GitHubClientID/Secret). GitHubAppEnabled gates the whole feature:
// off → /integrations/github/* and POST /webhooks/github reject with 501.
// All values are operator k8s secrets (NOT ConfigMap) — the private key can
// mint tokens for every installation.
GitHubAppEnabled bool
GitHubAppID string // GITHUB_APP_ID — numeric App ID (JWT iss)
GitHubAppSlug string // GITHUB_APP_SLUG — public slug for the install URL (github.com/apps/<slug>)
GitHubAppPrivateKey string // GITHUB_APP_PRIVATE_KEY — RSA PEM (RS256 signing)
GitHubAppWebhookSecret string // GITHUB_APP_WEBHOOK_SECRET — X-Hub-Signature-256 HMAC key
GitHubAppClientID string // GITHUB_APP_CLIENT_ID — for the install/callback OAuth handshake
GitHubAppClientSecret string // GITHUB_APP_CLIENT_SECRET

// 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 @@ -451,6 +465,21 @@ func Load() *Config {
cfg.DeploySourceGitEnabled = false
}

// GITHUB_APP_ENABLED: default FALSE (off until the operator registers the
// App and provisions GITHUB_APP_* secrets — see infra/GITHUB-APP-RUNBOOK.md).
switch strings.ToLower(strings.TrimSpace(os.Getenv("GITHUB_APP_ENABLED"))) {
case "true", "1", "yes":
cfg.GitHubAppEnabled = true
default:
cfg.GitHubAppEnabled = false
}
cfg.GitHubAppID = os.Getenv("GITHUB_APP_ID")
cfg.GitHubAppSlug = os.Getenv("GITHUB_APP_SLUG")
cfg.GitHubAppPrivateKey = os.Getenv("GITHUB_APP_PRIVATE_KEY")
cfg.GitHubAppWebhookSecret = os.Getenv("GITHUB_APP_WEBHOOK_SECRET")
cfg.GitHubAppClientID = os.Getenv("GITHUB_APP_CLIENT_ID")
cfg.GitHubAppClientSecret = os.Getenv("GITHUB_APP_CLIENT_SECRET")

if len(cfg.JWTSecret) < 32 {
panic("JWT_SECRET must be at least 32 bytes")
}
Expand Down
152 changes: 93 additions & 59 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ func allKeys() []string {
"METRICS_TOKEN", "DASHBOARD_BASE_URL", "API_PUBLIC_URL",
"DELETION_CONFIRMATION_TTL_MINUTES", "FAMILY_BINDINGS_ENABLED",
"DEPLOY_SOURCE_IMAGE_ENABLED", "DEPLOY_SOURCE_GIT_ENABLED",
"GITHUB_APP_ENABLED", "GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_PRIVATE_KEY",
"GITHUB_APP_WEBHOOK_SECRET", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET",
"BREVO_WEBHOOK_SECRET", "SES_SNS_SUBSCRIPTION_ARN",
"SENDGRID_WEBHOOK_PUBLIC_KEY",
"WORKER_INTERNAL_JWT_SECRET", "ADMIN_PATH_PREFIX",
Expand Down Expand Up @@ -248,6 +250,9 @@ func TestLoad_HappyPath_AppliesDefaults(t *testing.T) {
if cfg.DeploySourceGitEnabled {
t.Error("DeploySourceGitEnabled default must be false (off until operator canary)")
}
if cfg.GitHubAppEnabled {
t.Error("GitHubAppEnabled default must be false (off until operator registers the App)")
}
// 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 All @@ -256,48 +261,48 @@ func TestLoad_HappyPath_AppliesDefaults(t *testing.T) {

func TestLoad_OverrideDefaults(t *testing.T) {
applyBaselineEnv(t, map[string]string{
"PORT": "9090",
"REDIS_URL": "redis://r:6379",
"ENVIRONMENT": "production",
"INSTANT_ENABLED_SERVICES": "postgres",
"GEOLITE2_DB_PATH": "/data/geo.mmdb",
"RAZORPAY_KEY_ID": "rzp_test_x",
"RESEND_API_KEY": "re_x",
"BREVO_API_KEY": "br_x",
"EMAIL_PROVIDER": "brevo",
"EMAIL_FROM_ADDRESS": "noreply@x.dev",
"EMAIL_FROM_NAME": "X",
"GITHUB_CLIENT_ID": "gh-x",
"GOOGLE_CLIENT_ID": "g-x",
"GOOGLE_REDIRECT_URI": "https://x/callback",
"REDIS_PROVISION_BACKEND": "upstash",
"REDIS_PROVISION_HOST": "redis.x",
"MONGO_HOST": "mongo.x",
"POSTGRES_PROVISION_BACKEND": "neon",
"NEON_API_KEY": "nk-x",
"PROVISIONER_ADDR": "prov:50051",
"PROVISIONER_SECRET": "ps",
"NATS_HOST": "nats.x",
"QUEUE_BACKEND": "legacy_open",
"NATS_PUBLIC_HOST": "public.x",
"NATS_OPERATOR_SEED": "SO_seed",
"PORT": "9090",
"REDIS_URL": "redis://r:6379",
"ENVIRONMENT": "production",
"INSTANT_ENABLED_SERVICES": "postgres",
"GEOLITE2_DB_PATH": "/data/geo.mmdb",
"RAZORPAY_KEY_ID": "rzp_test_x",
"RESEND_API_KEY": "re_x",
"BREVO_API_KEY": "br_x",
"EMAIL_PROVIDER": "brevo",
"EMAIL_FROM_ADDRESS": "noreply@x.dev",
"EMAIL_FROM_NAME": "X",
"GITHUB_CLIENT_ID": "gh-x",
"GOOGLE_CLIENT_ID": "g-x",
"GOOGLE_REDIRECT_URI": "https://x/callback",
"REDIS_PROVISION_BACKEND": "upstash",
"REDIS_PROVISION_HOST": "redis.x",
"MONGO_HOST": "mongo.x",
"POSTGRES_PROVISION_BACKEND": "neon",
"NEON_API_KEY": "nk-x",
"PROVISIONER_ADDR": "prov:50051",
"PROVISIONER_SECRET": "ps",
"NATS_HOST": "nats.x",
"QUEUE_BACKEND": "legacy_open",
"NATS_PUBLIC_HOST": "public.x",
"NATS_OPERATOR_SEED": "SO_seed",
"NATS_SYSTEM_ACCOUNT_PUBLIC_KEY": "ACSYS",
"NATS_USE_TLS": "true",
"R2_ENDPOINT": "r2.x",
"R2_BUCKET_NAME": "x-bucket",
"R2_API_TOKEN": "r2tok",
"DEPLOY_DOMAIN": "x.dev",
"COMPUTE_PROVIDER": "k8s",
"KUBE_NAMESPACE_APPS": "x-apps",
"METRICS_TOKEN": strings.Repeat("M", 64),
"DASHBOARD_BASE_URL": "https://dash.x",
"API_PUBLIC_URL": "https://api.x/",
"BREVO_WEBHOOK_SECRET": "brevo-wh",
"SES_SNS_SUBSCRIPTION_ARN": "arn:aws:sns:x",
"SENDGRID_WEBHOOK_PUBLIC_KEY": "sg-key",
"WORKER_INTERNAL_JWT_SECRET": " worker-secret ",
"TRUSTED_PROXY_CIDRS": "10.0.0.0/8",
"MAXMIND_LICENSE_KEY": "mm",
"NATS_USE_TLS": "true",
"R2_ENDPOINT": "r2.x",
"R2_BUCKET_NAME": "x-bucket",
"R2_API_TOKEN": "r2tok",
"DEPLOY_DOMAIN": "x.dev",
"COMPUTE_PROVIDER": "k8s",
"KUBE_NAMESPACE_APPS": "x-apps",
"METRICS_TOKEN": strings.Repeat("M", 64),
"DASHBOARD_BASE_URL": "https://dash.x",
"API_PUBLIC_URL": "https://api.x/",
"BREVO_WEBHOOK_SECRET": "brevo-wh",
"SES_SNS_SUBSCRIPTION_ARN": "arn:aws:sns:x",
"SENDGRID_WEBHOOK_PUBLIC_KEY": "sg-key",
"WORKER_INTERNAL_JWT_SECRET": " worker-secret ",
"TRUSTED_PROXY_CIDRS": "10.0.0.0/8",
"MAXMIND_LICENSE_KEY": "mm",
})
cfg := Load()
if cfg.Port != "9090" || cfg.Environment != "production" {
Expand Down Expand Up @@ -368,6 +373,35 @@ func TestLoad_DeploySourceGitEnabled(t *testing.T) {
}
}

func TestLoad_GitHubAppEnabled(t *testing.T) {
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
applyBaselineEnv(t, map[string]string{"GITHUB_APP_ENABLED": val})
if !Load().GitHubAppEnabled {
t.Errorf("GITHUB_APP_ENABLED=%q should enable", val)
}
}
for _, val := range []string{"false", "0", "no", "maybe", ""} {
applyBaselineEnv(t, map[string]string{"GITHUB_APP_ENABLED": val})
if Load().GitHubAppEnabled {
t.Errorf("GITHUB_APP_ENABLED=%q should stay disabled", val)
}
}
// the GITHUB_APP_* values are plumbed verbatim.
applyBaselineEnv(t, map[string]string{
"GITHUB_APP_ID": "12345",
"GITHUB_APP_PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nx\n-----END RSA PRIVATE KEY-----",
"GITHUB_APP_WEBHOOK_SECRET": "whsec",
"GITHUB_APP_CLIENT_ID": "Iv1.abc",
"GITHUB_APP_CLIENT_SECRET": "cs",
})
c := Load()
if c.GitHubAppID != "12345" || c.GitHubAppWebhookSecret != "whsec" ||
c.GitHubAppClientID != "Iv1.abc" || c.GitHubAppClientSecret != "cs" ||
c.GitHubAppPrivateKey == "" {
t.Errorf("GITHUB_APP_* not plumbed: %+v", c.GitHubAppID)
}
}

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 Expand Up @@ -421,16 +455,16 @@ func TestLoad_ObjectStore_MinioFallback(t *testing.T) {

func TestLoad_ObjectStore_ExplicitOverridesFallback(t *testing.T) {
applyBaselineEnv(t, map[string]string{
"OBJECT_STORE_ENDPOINT": "nyc3.digitaloceanspaces.com",
"OBJECT_STORE_PUBLIC_URL": "https://s3.instanode.dev",
"OBJECT_STORE_ACCESS_KEY": "AKIA",
"OBJECT_STORE_SECRET_KEY": "SECRET",
"OBJECT_STORE_BUCKET": "do-bucket",
"OBJECT_STORE_REGION": "nyc3",
"OBJECT_STORE_SECURE": "true",
"OBJECT_STORE_ENDPOINT": "nyc3.digitaloceanspaces.com",
"OBJECT_STORE_PUBLIC_URL": "https://s3.instanode.dev",
"OBJECT_STORE_ACCESS_KEY": "AKIA",
"OBJECT_STORE_SECRET_KEY": "SECRET",
"OBJECT_STORE_BUCKET": "do-bucket",
"OBJECT_STORE_REGION": "nyc3",
"OBJECT_STORE_SECURE": "true",
"OBJECT_STORE_ALLOW_SHARED_KEY": "true",
// Set MINIO_* so we prove they DON'T win.
"MINIO_ENDPOINT": "minio:9000",
"MINIO_ENDPOINT": "minio:9000",
"MINIO_ROOT_USER": "ignored",
})
cfg := Load()
Expand Down Expand Up @@ -571,16 +605,16 @@ func TestLoad_RazorpayPlanIDs(t *testing.T) {
})
c := Load()
checks := map[string]string{
"hobby": c.RazorpayPlanIDHobby,
"hp": c.RazorpayPlanIDHobbyPlus,
"pro": c.RazorpayPlanIDPro,
"growth": c.RazorpayPlanIDGrowth,
"team": c.RazorpayPlanIDTeam,
"hobby_y": c.RazorpayPlanIDHobbyYearly,
"hp_y": c.RazorpayPlanIDHobbyPlusYearly,
"pro_y": c.RazorpayPlanIDProYearly,
"growth_y": c.RazorpayPlanIDGrowthYearly,
"team_y": c.RazorpayPlanIDTeamYearly,
"hobby": c.RazorpayPlanIDHobby,
"hp": c.RazorpayPlanIDHobbyPlus,
"pro": c.RazorpayPlanIDPro,
"growth": c.RazorpayPlanIDGrowth,
"team": c.RazorpayPlanIDTeam,
"hobby_y": c.RazorpayPlanIDHobbyYearly,
"hp_y": c.RazorpayPlanIDHobbyPlusYearly,
"pro_y": c.RazorpayPlanIDProYearly,
"growth_y": c.RazorpayPlanIDGrowthYearly,
"team_y": c.RazorpayPlanIDTeamYearly,
}
for tag, got := range checks {
want := "plan_" + tag
Expand Down
26 changes: 26 additions & 0 deletions internal/db/migrations/066_github_installations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- 066_github_installations.sql — GitHub App installations, P4.1.
--
-- WHY: P4 adds install-once push-to-deploy on top of the existing manual
-- per-repo webhook (app_github_connections). When a team installs the InstaNode
-- GitHub App, GitHub assigns an installation_id; we persist the installation↔team
-- link so that (a) the install/callback flow can bind it, (b) the App webhook can
-- resolve an incoming push's installation_id → owning team before acting, and
-- (c) the token-minter can mint a short-lived installation access token for
-- private-repo clones.
--
-- We store ONLY the installation_id + account metadata — never an access token
-- (those are minted on demand from the App private key and cached in Redis, 1h
-- TTL). suspended_at is set when GitHub sends an installation `suspend` event so
-- the webhook can stop acting without deleting the row.

CREATE TABLE IF NOT EXISTS github_installations (
installation_id BIGINT PRIMARY KEY,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
account_login TEXT NOT NULL DEFAULT '', -- the org/user the App is installed on
suspended_at TIMESTAMPTZ, -- non-NULL → installation suspended
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- A team may have multiple installations (personal + orgs); look them up by team.
CREATE INDEX IF NOT EXISTS idx_github_installations_team ON github_installations(team_id);
Loading
Loading