diff --git a/persys-gateway/README.md b/persys-gateway/README.md index dbe2539..fba14b2 100644 --- a/persys-gateway/README.md +++ b/persys-gateway/README.md @@ -5,11 +5,10 @@ ## Responsibilities - Public HTTP ingress. -- OAuth/session handling for GitHub login flow. +- OAuth/session handling for GitHub login flow (managed deployments only — see Deployment Modes). - GitHub webhook signature + replay validation. -- Multi-cluster scheduler pool routing. -- Proxy HTTP API calls to scheduler gRPC API. -- Forward forgery-related actions to forgery gRPC API. +- Multi-cluster scheduler pool routing, with automatic failover across scheduler replicas. +- Dynamic HTTP-to-gRPC bridging for cluster control (workloads/nodes) and forgery (CI/CD), via gRPC reflection with a compiled-in fallback — see Dynamic API Surface. - Enforce mTLS for internal calls. ## Non-Responsibilities @@ -18,40 +17,103 @@ - Does not push images. - Does not perform scheduler-side build actions. +## Deployment Modes + +Set via `deployment.mode` in `config.yaml` (or left unset): + +- **`self-hosted`** (default) — no GitHub OAuth app required. `/auth/*` and + `/github/*` routes aren't mounted at all. mTLS is the only trust + boundary for cluster-control and forgery routes. No database is + required — see Database below. +- **`managed`** — GitHub OAuth mounts, cluster-control/forgery routes + require a verified user JWT (or mTLS), and a database is required at + startup (fails fast if `database.dsn` is empty). + +`GET /health` reports the active `deployment_mode` and `database_enabled` +so this is always visible at runtime, not just inferred from config. + +## Database + +Postgres, via `internal/store` — **optional in self-hosted mode**. Leave +`database.dsn` unset and the gateway runs with no database at all: the +only things that ever touch it (OAuth login/session storage, webhook +delivery audit trail) either aren't mounted in self-hosted mode or +degrade gracefully to in-memory-only behavior. Managed mode requires it. + +Schema is three tables (`users`, `oauth_sessions`, `webhook_events`), +applied as idempotent `CREATE TABLE IF NOT EXISTS` on every startup — +no separate migration command. See `internal/store/schema.sql` for what +each table is for and what was deliberately *not* carried over from an +earlier MongoDB-based version. + ## Ports From `config.yaml`: - mTLS API: `:8551` - public webhook API: `:8585` +- debug/pprof: `:6060` ## Config Primary config files: - `config.yaml` - `cluster.yaml` (scheduler clusters and routing) +- `catalog.yaml` (optional — see Dynamic API Surface; absence is normal) Important sections: +- `deployment.mode` — see Deployment Modes +- `app.jwt_secret` — required in managed mode, auto-generated with a + startup warning in self-hosted (won't survive a restart unless set) +- `database.dsn` — required in managed mode, optional in self-hosted - `tls`, `vault` - `scheduler` + `core_dns` - `webhook` - `forgery.grpc_addr`, `forgery.grpc_server_name` +Key environment variable overrides (see `config/config.go` for the full +list): `PERSYS_GATEWAY_CONFIG`, `PERSYS_GATEWAY_JWT_SECRET`, +`PERSYS_GATEWAY_POSTGRES_DSN`, `PERSYS_GATEWAY_CATALOG`. + +## Dynamic API Surface + +Cluster-control (workloads/nodes) and forgery (CI/CD) routes are not +hand-written per RPC. `internal/grpcbridge` discovers methods via gRPC +reflection against the live backend, falling back to the compiled-in +proto descriptor if the backend doesn't support reflection yet — so a +new RPC on either backend is reachable with zero gateway code changes, +and works against existing deployments unmodified either way. + +The full, current list of stable paths is `internal/router/bindings.go`. +Anything not given a stable alias there is still callable at the generic +`/clusters/:cluster_id/rpc//` path, and every method +(aliased or not) is listed at runtime: + +``` +GET /clusters/:cluster_id/rpc/_meta +GET /clusters/:cluster_id/forgery/rpc/_meta +``` + ## Key Routes Public: - `POST /webhooks/github` mTLS API: +- `GET /health` - `GET /clusters` -- `POST /workloads/schedule` -- `GET /workloads` -- `GET /nodes` -- `GET /cluster/metrics` -- `POST /forgery/projects/upsert` -- `POST /forgery/builds/trigger` -- `POST /forgery/webhooks/test` - -Cluster-scoped variants are under `/clusters/:cluster_id/...`. +- `GET /clusters/:cluster_id` +- `POST /clusters/:cluster_id/workloads/schedule` +- `GET /clusters/:cluster_id/workloads` +- `GET /clusters/:cluster_id/nodes` +- `GET /clusters/:cluster_id/cluster/metrics` +- `POST /clusters/:cluster_id/forgery/projects/upsert` +- `POST /clusters/:cluster_id/forgery/builds/trigger` +- `POST /clusters/:cluster_id/forgery/webhooks/test` + +Managed mode only: +- `GET /auth/login` +- `GET /auth/` (OAuth callback) +- `GET /github/list/repos` ## Run @@ -66,3 +128,6 @@ go run ./cmd cd persys-gateway go build ./cmd ``` + +After pulling dependency changes (e.g. the Postgres migration), run +`go mod tidy` once to settle `go.sum`. \ No newline at end of file diff --git a/persys-gateway/cmd/main.go b/persys-gateway/cmd/main.go index ca82671..245b1ec 100755 --- a/persys-gateway/cmd/main.go +++ b/persys-gateway/cmd/main.go @@ -17,16 +17,17 @@ import ( "github.com/gin-gonic/gin" "github.com/persys-dev/persys-cloud/persys-gateway/config" "github.com/persys-dev/persys-cloud/persys-gateway/controllers" - "github.com/persys-dev/persys-cloud/pkg/certmanager" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/authn" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/catalog" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/grpcbridge" "github.com/persys-dev/persys-cloud/persys-gateway/internal/middleware" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/router" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/store" "github.com/persys-dev/persys-cloud/persys-gateway/routes" "github.com/persys-dev/persys-cloud/persys-gateway/services" + "github.com/persys-dev/persys-cloud/pkg/certmanager" "github.com/sirupsen/logrus" ginprometheus "github.com/zsais/go-gin-prometheus" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" - "go.mongodb.org/mongo-driver/mongo/readpref" gootelgin "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" @@ -36,24 +37,30 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.17.0" ) +// App holds every service/controller constructed at startup. Renamed +// fields from the old ProwService/ProwController naming, which didn't +// correspond to any real Persys service — see ClusterControlService and +// ClusterMetaController. ForgeryService is new: forgery used to share +// ProwService's pool/failover shape despite being a single fixed +// address with nothing to fail over between. +// +// db is Postgres via internal/store, replacing MongoDB entirely — see +// internal/store/schema.sql for the (much shorter) list of what's +// actually persisted now. type App struct { - server *gin.Engine - authCollection *mongo.Collection - sessionCollection *mongo.Collection - clusterCollection *mongo.Collection - githubCollection *mongo.Collection - prowCollection *mongo.Collection - webhookCollection *mongo.Collection - authService services.AuthService - githubService services.GithubService - prowService *services.ProwService - webhookService services.WebhookService - automationService *services.AutomationService - authController controllers.AuthController - githubController controllers.GithubController - prowController *controllers.ProwController - webhookController *controllers.WebhookController - automationController *controllers.AutomationController + server *gin.Engine + db *store.Store + authService services.AuthService + githubService services.GithubService + clusterControl *services.ClusterControlService + forgeryService *services.ForgeryService + webhookService services.WebhookService + automationService *services.AutomationService + authController controllers.AuthController + githubController controllers.GithubController + clusterMetaController *controllers.ClusterMetaController + webhookController *controllers.WebhookController + automationController *controllers.AutomationController } func setupTracer(endpoint string, serviceName string) func() { @@ -94,7 +101,7 @@ func main() { shutdown := setupTracer(cnf.Telemetry.OTLPEndpoint, cnf.ServiceName) defer shutdown() - log.Printf("bootstrapping %s", cnf.ServiceName) + log.Printf("bootstrapping %s (deployment.mode=%s)", cnf.ServiceName, cnf.Deployment.Mode) certcnf := certmanager.Config{ TLSEnabled: cnf.TLS.Enabled, @@ -126,33 +133,47 @@ func main() { log.Fatalf("failed to start vault cert manager: %v", err) } - mongoclient, err := setupMongoDB(ctx, cnf.Database.MongoURI) - if err != nil { - log.Fatalf("failed to setup MongoDB: %v", err) + var db *store.Store + if cnf.Database.Enabled() { + db, err = store.New(ctx, cnf.Database.DSN, cnf.Database.MaxConns) + if err != nil { + log.Fatalf("failed to connect to Postgres: %v", err) + } + defer db.Close() + if err := db.Migrate(ctx); err != nil { + log.Fatalf("failed to migrate database: %v", err) + } + log.Println("Postgres connected and schema migrated") + } else { + // Valid and expected for self-hosted: user/session/OAuth storage + // is only touched by /auth and /github routes, which only mount + // in managed mode (see below); webhook.service.go's audit + // persistence no-ops on a nil store and falls back to + // in-memory-only replay tracking. Managed mode can't reach this + // branch — config.LoadConfig already failed fast above if + // database.dsn was empty there. + log.Println("no database configured — running without persistent user/session/webhook-audit storage " + + "(expected for self-hosted; set database.dsn to enable it, or run in managed mode where it's required)") } - defer mongoclient.Disconnect(ctx) app := &App{ - server: gin.Default(), - authCollection: mongoclient.Database(cnf.Database.Name).Collection("users"), - sessionCollection: mongoclient.Database(cnf.Database.Name).Collection("sessions"), - clusterCollection: mongoclient.Database(cnf.Database.Name).Collection("cluster_state"), - githubCollection: mongoclient.Database(cnf.Database.Name).Collection("repos"), - prowCollection: mongoclient.Database(cnf.Database.Name).Collection("prow"), - webhookCollection: mongoclient.Database(cnf.Database.Name).Collection("webhooks"), + server: gin.Default(), + db: db, } + jwtSecret := []byte(cnf.App.JWTSecret) + webhookTLS, err := buildMTLSClientConfig(cnf) if err != nil { log.Fatalf("failed to initialize webhook forwarding TLS: %v", err) } - app.authService = services.NewAuthService(app.authCollection, ctx) - app.githubService = services.NewGithubService(app.githubCollection, ctx, cnf, webhookTLS) - app.prowService = services.NewProwService(cnf) - app.prowService.Start(ctx) - go persistClusterSnapshots(ctx, app.clusterCollection, app.prowService) - app.webhookService, err = services.NewWebhookService(cnf, webhookTLS, app.webhookCollection) + app.authService = services.NewAuthService(app.db, ctx, jwtSecret) + app.githubService = services.NewGithubService(cnf, webhookTLS) + app.clusterControl = services.NewClusterControlService(cnf) + app.clusterControl.Start(ctx) + app.forgeryService = services.NewForgeryService(cnf, webhookTLS) + app.webhookService, err = services.NewWebhookService(cnf, webhookTLS, app.db) if err != nil { log.Fatalf("failed to initialize webhook service: %v", err) } @@ -163,12 +184,20 @@ func main() { log.Fatalf("failed to initialize automation service: %v", err) } - app.authController = controllers.NewAuthController(app.authService, ctx, app.githubService, app.authCollection, app.sessionCollection) - app.githubController = controllers.NewGithubController(app.authService, ctx, app.githubService, app.githubCollection, cnf) - app.prowController = controllers.NewProwController(app.prowService, app.authService, ctx) + app.authController = controllers.NewAuthController( + app.authService, ctx, app.githubService, app.db, + cnf.GitHub.Auth.ClientID, cnf.GitHub.Auth.ClientSecret, jwtSecret, + ) + app.githubController = controllers.NewGithubController(app.authService, ctx, app.githubService, cnf) + app.clusterMetaController = controllers.NewClusterMetaController(app.clusterControl, string(cnf.Deployment.Mode), cnf.Database.Enabled()) app.webhookController = controllers.NewWebhookController(app.webhookService) app.automationController = controllers.NewAutomationController(app.automationService) + // grpcbridge classifies invocation errors from services.ClusterControlService + // without importing the services package (avoids an import cycle, + // since services doesn't and shouldn't import grpcbridge). + grpcbridge.RegisterErrorClassifiers(services.IsUnknownCluster, services.IsSchedulerUnavailable) + corsConfig := cors.DefaultConfig() corsConfig.AllowOrigins = []string{"*"} corsConfig.AllowCredentials = true @@ -197,18 +226,108 @@ func main() { ctx.JSON(http.StatusOK, gin.H{"status": "success", "message": "Persys Gateway running", "version": "1.0.0"}) }) - authRouteController := routes.NewAuthRouteController(app.authController, cnf.App.OAuthRedirectURL) - githubRouteController := routes.NewGithubRouteController(app.githubController) - prowRouteController := routes.NewProwRouteController(app.prowController) - webhookRouteController := routes.NewWebhookRouteController(app.webhookController) + authnMW := authn.New(jwtSecret) + gwRouter := router.New(authnMW, cnf.Deployment.Mode) + + // ClusterMetaController: health/list-clusters/get-cluster — the only + // handlers left that were never RPC-shaped. + gwRouter.RegisterControllers(mtlsGroup, app.clusterMetaController) + + // Optional service catalog for plain reverse-proxy services added + // later with zero gateway code changes. Not having one yet (no + // catalog.yaml on disk) is a normal out-of-the-box state, not an + // error — RegisterCatalog no-ops in that case. + catalogPath := os.Getenv("PERSYS_GATEWAY_CATALOG") + if catalogPath == "" { + catalogPath = "catalog.yaml" + } + if err := gwRouter.RegisterCatalog(mtlsGroup, catalogPath); err != nil { + log.Fatalf("failed to load service catalog %q: %v", catalogPath, err) + } + + // Dynamic RPC bridge: cluster control (pooled, HA-aware failover) and + // forgery (single fixed address) on one Bridge, each with its own + // Invoker/Source so neither shape leaks into the other. See + // internal/router/bindings.go for the full list of what's callable + // and where. Both bindings fall back to their compiled-in proto + // descriptor (LocalFile) if the backend doesn't support gRPC + // reflection yet — so this works against existing scheduler/forgery + // deployments unmodified, and upgrades to live discovery + // automatically once they add reflection.Register. + // + // Mounted TWICE, on purpose: + // + // - v2, cluster-scoped: /clusters/:cluster_id/workloads/schedule etc. + // Explicit multi-cluster targeting. + // + // - v1, flat: /workloads/schedule etc. — no cluster segment at all. + // This is the ORIGINAL route shape persysctl (and anyone else + // built against pre-multi-cluster persys-gateway) already calls. + // It keeps working unmodified: DefaultKeyResolver.ResolveClusterID + // returns "" when there's no :cluster_id param and no + // X-Persys-Cluster-ID header/query override, and + // ClusterControlService/ForgeryService.InvokeDynamic both already + // fall back to schedulerPool.DefaultClusterID() when clusterID is + // "". So v1 callers transparently hit the default cluster with + // zero gateway-side special-casing beyond mounting the routes + // twice — the dynamic-discovery and failover logic underneath is + // shared, not duplicated. + // + // Costs two independent reflection-discovery cycles instead of one + // (each Bridge instance discovers/refreshes separately) — negligible + // at a 60s refresh interval, and far simpler than trying to make one + // Bridge serve two mount points off one discovery pass. + clusterGroup := mtlsGroup.Group("/clusters/:cluster_id") + bridgeV2 := grpcbridge.New() + if err := bridgeV2.Register(clusterGroup, + router.ClusterControlBinding(app.clusterControl, gwRouter), + router.ForgeryBinding(app.forgeryService, gwRouter), + ); err != nil { + log.Fatalf("failed to register grpc bridge (v2, cluster-scoped): %v", err) + } + + bridgeV1 := grpcbridge.New() + if err := bridgeV1.Register(mtlsGroup, + router.ClusterControlBinding(app.clusterControl, gwRouter), + router.ForgeryBinding(app.forgeryService, gwRouter), + ); err != nil { + log.Fatalf("failed to register grpc bridge (v1, flat/legacy): %v", err) + } + + // GitHub OAuth: only meaningful, and only mounted, in managed mode. + // A self-hosted operator never sees these routes at all and never + // needs a GitHub OAuth app. + if cnf.Deployment.Mode == config.DeploymentManaged { + authRouteController := routes.NewAuthRouteController(app.authController, cnf.App.OAuthRedirectURL) + githubRouteController := routes.NewGithubRouteController(app.authController, app.githubController) + authRouteController.AuthRoute(mtlsGroup) + githubRouteController.GithubRoute(mtlsGroup) + } else { + log.Printf("deployment.mode=%s: GitHub OAuth routes are not mounted (no OAuth app needed for self-hosted installs)", cnf.Deployment.Mode) + } + + // Automation previously had NO auth enforced at all — every group + // mounted here except /auth and /github skipped it entirely. Now + // explicitly resolved against deployment mode like everything else. + automationGroup := mtlsGroup.Group("") + automationGroup.Use(gwRouter.Resolve(catalog.AuthUser)) automationRouteController := routes.NewAutomationRouteController(app.automationController) - intelligenceRouteController := routes.NewIntelligenceRouteController(cnf) + automationRouteController.AutomationRoute(automationGroup) - authRouteController.AuthRoute(mtlsGroup) - githubRouteController.GithubRoute(mtlsGroup) - prowRouteController.ProwRoute(mtlsGroup) + // Webhook stays unauthenticated at the gateway level by design — its + // own HMAC signature verification (X-Hub-Signature-256) IS its auth + // mechanism, checked inside webhook.service.go. Mounted on the + // non-mTLS listener since GitHub calls in from the public internet. + webhookRouteController := routes.NewWebhookRouteController(app.webhookController) webhookRouteController.WebhookRoute(nonMTLSGroup, cnf.Webhook.PublicPath) - automationRouteController.AutomationRoute(mtlsGroup) + + // Intelligence: left as its own hand-written proxy controller rather + // than folded into the service catalog. Its route-by-route prefix + // handling (some paths keep "/ai", the :id action paths strip it) is + // existing, presumably-working behavior against the real + // persys-intelligence service that a blanket catalog entry can't + // safely reproduce without risking a silent path mismatch. + intelligenceRouteController := routes.NewIntelligenceRouteController(cnf) intelligenceRouteController.IntelligenceRoute(mtlsGroup) caCert, err := os.ReadFile(cnf.TLS.CAPath) @@ -284,21 +403,9 @@ func main() { log.Printf("public server shutdown failed: %v", err) } if err := debugServer.Shutdown(shutdownCtx); err != nil { - log.Printf("debug server shutdown failed: %v", err) -} - log.Println("servers exited gracefully") -} - -func setupMongoDB(ctx context.Context, uri string) (*mongo.Client, error) { - client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri).SetMaxPoolSize(50).SetMinPoolSize(5)) - if err != nil { - return nil, fmt.Errorf("failed to connect to MongoDB: %w", err) + log.Printf("debug server shutdown failed: %v", err) } - if err := client.Ping(ctx, readpref.Primary()); err != nil { - return nil, fmt.Errorf("failed to ping MongoDB: %w", err) - } - fmt.Println("MongoDB successfully connected") - return client, nil + log.Println("servers exited gracefully") } func buildMTLSClientConfig(cnf *config.Config) (*tls.Config, error) { @@ -316,47 +423,3 @@ func buildMTLSClientConfig(cnf *config.Config) (*tls.Config, error) { } return &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: caPool}, nil } - -func persistClusterSnapshots(ctx context.Context, collection *mongo.Collection, prowService *services.ProwService) { - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - - persist := func() { - for _, cluster := range prowService.SnapshotClusters() { - schedulers := make([]bson.M, 0, len(cluster.Schedulers)) - for _, sch := range cluster.Schedulers { - schedulers = append(schedulers, bson.M{ - "id": sch.ID, - "address": sch.Address, - "is_leader": sch.IsLeader, - "healthy": sch.Healthy, - "last_seen": sch.LastSeen, - }) - } - _, err := collection.UpdateOne(ctx, - bson.M{"cluster_id": cluster.ID}, - bson.M{"$set": bson.M{ - "cluster_id": cluster.ID, - "name": cluster.Name, - "routing_strategy": string(cluster.RoutingStrategy), - "schedulers": schedulers, - "updated_at": time.Now().UTC(), - }}, - options.Update().SetUpsert(true), - ) - if err != nil { - log.Printf("failed to persist cluster snapshot cluster=%s err=%v", cluster.ID, err) - } - } - } - - persist() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - persist() - } - } -} diff --git a/persys-gateway/config.yaml b/persys-gateway/config.yaml index b5bfc1c..ad5780f 100644 --- a/persys-gateway/config.yaml +++ b/persys-gateway/config.yaml @@ -1,18 +1,34 @@ service_name: persys-gateway +# Single toggle deciding the whole deployment posture. "self-hosted" +# (default) needs no GitHub OAuth app and no database — see +# database.dsn below and README.md's "Deployment Modes" section. +# "managed" mounts GitHub OAuth and requires database.dsn to be set +# (fails fast at startup otherwise). +deployment: + mode: self-hosted + app: http_addr: ":8551" http_addr_public: ":8585" grpc_addr: ":8661" - storage: mongodb oauth_redirect_url: "http://localhost:8585/auth" metadata: name: persys-gateway + # Signs/verifies user session JWTs. Leave unset for self-hosted local + # dev — a random secret is generated at startup with a warning (won't + # survive a restart). Set explicitly via PERSYS_GATEWAY_JWT_SECRET + # (preferred) or here for anything long-lived, and required if + # deployment.mode is "managed". + jwt_secret: "" +# Postgres. Leave dsn empty for self-hosted — the gateway runs with no +# database at all in that mode (see README.md). Required if +# deployment.mode is "managed". Prefer PERSYS_GATEWAY_POSTGRES_DSN over +# committing a real DSN here. database: - mongo_uri: "mongodb://admin:admin@localhost:27017/persys-gateway?authSource=admin" - collections: ["events", "repos", "users"] - name: persys-gateway + dsn: "" + max_conns: 10 tls: enabled: true @@ -23,6 +39,7 @@ tls: vault: enabled: true + manager_addr: "" addr: "http://localhost:8200" auth_method: "approle" token: "" @@ -39,9 +56,13 @@ vault: core_dns: addr: "coredns:53" -prow: - scheduler_addr: "persys-scheduler:8085" - enable_proxy: true +# Fallback path used only when the scheduler pool (see cluster.yaml) +# can't resolve a candidate for a cluster — e.g. a single-scheduler +# deployment that hasn't set up cluster.yaml at all. Renamed from +# "prow", which didn't describe what this does. +legacy_scheduler: + fallback_addr: "persys-scheduler:8085" + proxy_enabled: true discovery_domain: "persys.local" discovery_service: "_persys-scheduler" @@ -51,6 +72,7 @@ scheduler: discovery_interval: "30s" request_timeout: "10s" +# GitHub OAuth — only read/used when deployment.mode is "managed". github: webhook_url: "http://persys.eastus.cloudapp.azure.com/webhooks/github" default_secret: "" diff --git a/persys-gateway/config/config.go b/persys-gateway/config/config.go index d61b0cf..4118222 100755 --- a/persys-gateway/config/config.go +++ b/persys-gateway/config/config.go @@ -1,7 +1,10 @@ package config import ( + "crypto/rand" + "encoding/hex" "fmt" + "log" "os" "strings" "time" @@ -15,21 +18,50 @@ const ( ) type Config struct { - ServiceName string `yaml:"service_name"` - App AppConfig `yaml:"app"` - Database DatabaseConfig `yaml:"database"` - TLS TLSConfig `yaml:"tls"` - Vault VaultConfig `yaml:"vault"` - CoreDNS CoreDNSConfig `yaml:"core_dns"` - Prow ProwConfig `yaml:"prow"` - Scheduler SchedulerConfig `yaml:"scheduler"` - GitHub GitHubConfig `yaml:"github"` - Webhook WebhookConfig `yaml:"webhook"` - Forgery ForgeryConfig `yaml:"forgery"` - Automation AutomationConfig `yaml:"automation"` - Intelligence IntelligenceConfig `yaml:"intelligence"` - Log LogConfig `yaml:"log"` - Telemetry TelemetryConfig `yaml:"telemetry"` + ServiceName string `yaml:"service_name"` + App AppConfig `yaml:"app"` + Database DatabaseConfig `yaml:"database"` + TLS TLSConfig `yaml:"tls"` + Vault VaultConfig `yaml:"vault"` + CoreDNS CoreDNSConfig `yaml:"core_dns"` + Deployment DeploymentConfig `yaml:"deployment"` + LegacyScheduler LegacySchedulerConfig `yaml:"legacy_scheduler"` + Scheduler SchedulerConfig `yaml:"scheduler"` + GitHub GitHubConfig `yaml:"github"` + Webhook WebhookConfig `yaml:"webhook"` + Forgery ForgeryConfig `yaml:"forgery"` + Automation AutomationConfig `yaml:"automation"` + Intelligence IntelligenceConfig `yaml:"intelligence"` + Log LogConfig `yaml:"log"` + Telemetry TelemetryConfig `yaml:"telemetry"` +} + +// DeploymentMode is the single toggle deciding whether the gateway runs +// as a self-hosted, single-tenant control plane (default — the +// open-source posture, no GitHub OAuth app required) or as the backend +// for a managed, multi-tenant offering (GitHub OAuth mounts, JWT auth is +// enforced on customer-facing routes, cluster ownership is checked). +type DeploymentMode string + +const ( + DeploymentSelfHosted DeploymentMode = "self-hosted" + DeploymentManaged DeploymentMode = "managed" +) + +type DeploymentConfig struct { + Mode DeploymentMode `yaml:"mode"` +} + +// LegacySchedulerConfig is a fallback path, used only when the scheduler +// pool (SchedulerConfig.Clusters) can't resolve a candidate — e.g. a +// single-scheduler deployment that hasn't set up cluster.yaml at all. +// Renamed from ProwConfig, which didn't describe anything about what +// this actually does. +type LegacySchedulerConfig struct { + FallbackAddr string `yaml:"fallback_addr"` + ProxyEnabled bool `yaml:"proxy_enabled"` + DiscoveryDomain string `yaml:"discovery_domain"` + DiscoverySvc string `yaml:"discovery_service"` } type AppConfig struct { @@ -39,12 +71,35 @@ type AppConfig struct { Storage string `yaml:"storage"` Metadata map[string]string `yaml:"metadata"` OAuthRedirectURL string `yaml:"oauth_redirect_url"` + // JWTSecret signs/verifies user session tokens. Never hardcode this — + // set PERSYS_GATEWAY_JWT_SECRET (or app.jwt_secret in config.yaml, + // not recommended for anything but local dev). If left empty in + // self-hosted mode, a random secret is generated at boot (fine: self + // -hosted's default auth mode doesn't depend on it — see + // catalog.AuthMode.Resolve). Required to be set explicitly in managed + // mode; LoadConfig fails fast otherwise. + JWTSecret string `yaml:"jwt_secret"` } type DatabaseConfig struct { - MongoURI string `yaml:"mongo_uri"` - Collections []string `yaml:"collections"` - Name string `yaml:"name"` + // DSN is a standard Postgres connection string, e.g. + // "postgres://persys:persys@localhost:5432/persys_gateway?sslmode=disable". + // Empty is valid in self-hosted mode — see Enabled. Always set this + // via PERSYS_GATEWAY_POSTGRES_DSN in managed mode, where it's + // required (LoadConfig fails fast otherwise). + DSN string `yaml:"dsn"` + // MaxConns bounds the connection pool. Defaults to 10. + MaxConns int32 `yaml:"max_conns"` +} + +// Enabled reports whether a database is configured at all. False is a +// normal, fully-supported state in self-hosted mode: user/session/OAuth +// storage is only touched by code paths that only mount in managed mode +// (see routes gating in cmd/main.go), and webhook.service.go's audit +// persistence already no-ops when its store is nil, falling back to +// in-memory-only replay tracking. +func (d DatabaseConfig) Enabled() bool { + return strings.TrimSpace(d.DSN) != "" } type TLSConfig struct { @@ -56,27 +111,20 @@ type TLSConfig struct { } type VaultConfig struct { - Enabled bool `yaml:"enabled"` - ManagerAddr string `yaml:"manager_addr"` - Addr string `yaml:"addr"` - AuthMethod string `yaml:"auth_method"` - Token string `yaml:"token"` - AppRoleID string `yaml:"approle_id"` - AppSecretID string `yaml:"approle_secret_id"` - PKIMount string `yaml:"pki_mount"` - PKIRole string `yaml:"pki_role"` + Enabled bool `yaml:"enabled"` + ManagerAddr string `yaml:"manager_addr"` + Addr string `yaml:"addr"` + AuthMethod string `yaml:"auth_method"` + Token string `yaml:"token"` + AppRoleID string `yaml:"approle_id"` + AppSecretID string `yaml:"approle_secret_id"` + PKIMount string `yaml:"pki_mount"` + PKIRole string `yaml:"pki_role"` CertTTL time.Duration `yaml:"cert_ttl"` RetryInterval time.Duration `yaml:"retry_interval"` - ServiceName string `yaml:"service_name"` - ServiceDomain string `yaml:"service_domain"` - BindHost string `yaml:"bind_host"` -} - -type ProwConfig struct { - SchedulerAddr string `yaml:"scheduler_addr"` - EnableProxy bool `yaml:"enable_proxy"` - DiscoveryDomain string `yaml:"discovery_domain"` - DiscoverySvc string `yaml:"discovery_service"` + ServiceName string `yaml:"service_name"` + ServiceDomain string `yaml:"service_domain"` + BindHost string `yaml:"bind_host"` } type CoreDNSConfig struct { @@ -168,8 +216,28 @@ func LoadConfig() (*Config, error) { return nil, err } - cfg.applyDefaults() cfg.applyEnvOverrides() + + // Deliberately checked BEFORE applyDefaults fills in an auto-generated + // JWT secret: in managed (multi-tenant) mode, a secret that resets on + // every restart would silently invalidate every customer session, and + // a secret nobody chose is a worse security posture than refusing to + // start. Self-hosted mode doesn't have this problem — its default + // auth mode never depends on the JWT secret in the first place. + if cfg.Deployment.Mode == DeploymentManaged && strings.TrimSpace(cfg.App.JWTSecret) == "" { + return nil, fmt.Errorf("app.jwt_secret (or PERSYS_GATEWAY_JWT_SECRET) is required when deployment.mode is %q", DeploymentManaged) + } + // Same reasoning as the JWT secret check above: managed mode has a + // real, ongoing need for user/session storage (OAuth login, + // cluster ownership down the line), so a missing database there is + // a startup-time misconfiguration, not something to silently paper + // over. Self-hosted has no such requirement — see + // DatabaseConfig.Enabled and the comment in applyDefaults. + if cfg.Deployment.Mode == DeploymentManaged && strings.TrimSpace(cfg.Database.DSN) == "" { + return nil, fmt.Errorf("database.dsn (or PERSYS_GATEWAY_POSTGRES_DSN) is required when deployment.mode is %q", DeploymentManaged) + } + + cfg.applyDefaults() if err := cfg.validate(); err != nil { return nil, err } @@ -181,12 +249,42 @@ func (c *Config) applyDefaults() { if strings.TrimSpace(c.ServiceName) == "" { c.ServiceName = "persys-gateway" } + if strings.TrimSpace(string(c.Deployment.Mode)) == "" { + c.Deployment.Mode = DeploymentSelfHosted + } + if strings.TrimSpace(c.App.JWTSecret) == "" { + // Only reachable here in self-hosted mode — LoadConfig already + // failed fast for managed mode with no secret set. A random + // secret is fine for self-hosted: its default auth resolution + // (catalog.AuthMode.Resolve) doesn't route customer-facing + // routes through JWT verification in the first place. It does + // mean any token issued before a restart stops verifying after + // one, which is acceptable for a single-operator deployment but + // worth knowing about. + secret, err := randomHexSecret(32) + if err != nil { + log.Fatalf("failed to generate a random JWT secret: %v", err) + } + c.App.JWTSecret = secret + log.Printf("WARNING: app.jwt_secret not set — generated a random secret for this process only. " + + "Set PERSYS_GATEWAY_JWT_SECRET explicitly if you need sessions to survive a restart.") + } if strings.TrimSpace(c.App.HTTPAddr) == "" { c.App.HTTPAddr = ":8551" } if strings.TrimSpace(c.App.HTTPAddrPublic) == "" { c.App.HTTPAddrPublic = ":8585" } + // No default DSN. An empty database.dsn is a real, supported state + // for self-hosted: user/session/OAuth storage is only touched by + // code paths (/auth/*, /github/*) that only mount in managed mode, + // and webhook.service.go already degrades to in-memory-only replay + // tracking with no DB — see DatabaseConfig.Enabled and main.go. + // Managed mode requires a DSN and fails fast at LoadConfig if it's + // missing (see below, mirroring the JWT secret check). + if c.Database.MaxConns <= 0 { + c.Database.MaxConns = 10 + } if strings.TrimSpace(c.Webhook.PublicPath) == "" { c.Webhook.PublicPath = "/webhooks/github" } @@ -288,12 +386,6 @@ func (c *Config) loadClusterConfig() error { } func (c *Config) validate() error { - if strings.TrimSpace(c.Database.MongoURI) == "" { - return fmt.Errorf("database.mongo_uri is required") - } - if strings.TrimSpace(c.Database.Name) == "" { - return fmt.Errorf("database.name is required") - } if strings.TrimSpace(c.TLS.CertPath) == "" || strings.TrimSpace(c.TLS.KeyPath) == "" || strings.TrimSpace(c.TLS.CAPath) == "" { return fmt.Errorf("tls.cert_path, tls.key_path and tls.ca_path are required") } @@ -301,11 +393,11 @@ func (c *Config) validate() error { } func (c *Config) applyEnvOverrides() { + // Auth + c.App.JWTSecret = envOrFile("PERSYS_GATEWAY_JWT_SECRET", c.App.JWTSecret) + // Database - c.Database.MongoURI = envOrFile("PERSYS_GATEWAY_MONGO_URI", c.Database.MongoURI) - if v := envOrFile("PERSYS_GATEWAY_DB_NAME", ""); v != "" { - c.Database.Name = v - } + c.Database.DSN = envOrFile("PERSYS_GATEWAY_POSTGRES_DSN", c.Database.DSN) // TLS paths c.TLS.CertPath = envOrFile("PERSYS_GATEWAY_TLS_CERT_PATH", c.TLS.CertPath) @@ -360,3 +452,11 @@ func envOrFile(key, fallback string) string { } return fallback } + +func randomHexSecret(numBytes int) (string, error) { + buf := make([]byte, numBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/persys-gateway/controllers/auth.controller.go b/persys-gateway/controllers/auth.controller.go index 0c0f9f1..3bea3f9 100755 --- a/persys-gateway/controllers/auth.controller.go +++ b/persys-gateway/controllers/auth.controller.go @@ -10,46 +10,53 @@ import ( "github.com/dgrijalva/jwt-go/request" "github.com/gin-gonic/gin" "github.com/google/go-github/github" - "github.com/persys-dev/persys-cloud/persys-gateway/config" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/store" "github.com/persys-dev/persys-cloud/persys-gateway/models" "github.com/persys-dev/persys-cloud/persys-gateway/services" "github.com/persys-dev/persys-cloud/persys-gateway/utils" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" "golang.org/x/oauth2" oauth2gh "golang.org/x/oauth2/github" ) -var ( - conf *oauth2.Config - state string - users *models.UserInput - repos *models.Repos - mySuperSecretPassword = "unicornsAreAwesome" - cnf, _ = config.LoadConfig() -) - -type Credentials struct { - ClientID string `json:"clientid"` - ClientSecret string `json:"secret"` -} - +// AuthController previously depended on three package-level mutable +// variables: `conf` (*oauth2.Config, written by Setup, read by every +// request), `state` (the last-issued CSRF token, overwritten on every +// login attempt — a real race under concurrent logins), and `cnf` (a +// second, independent config.LoadConfig() call happening at package +// init, racing whatever main.go's own load did). All three are gone: +// oauthConfig and jwtSecret are receiver fields set once at construction, +// and the CSRF token lives in Postgres (oauth_sessions table) keyed by +// the token itself, not remembered in a Go variable at all. type AuthController struct { authService services.AuthService githubService services.GithubService - //userService services.UserService - ctx context.Context - collection *mongo.Collection - sessionCollection *mongo.Collection + ctx context.Context + store *store.Store + + oauthConfig *oauth2.Config + jwtSecret []byte } -func NewAuthController(authService services.AuthService, ctx context.Context, githubService services.GithubService, collection *mongo.Collection, sessionCollection *mongo.Collection) AuthController { +func NewAuthController( + authService services.AuthService, + ctx context.Context, + githubService services.GithubService, + st *store.Store, + githubClientID string, + githubClientSecret string, + jwtSecret []byte, +) AuthController { return AuthController{ - authService: authService, - githubService: githubService, - ctx: ctx, - collection: collection, - sessionCollection: sessionCollection, + authService: authService, + githubService: githubService, + ctx: ctx, + store: st, + oauthConfig: &oauth2.Config{ + ClientID: githubClientID, + ClientSecret: githubClientSecret, + Endpoint: oauth2gh.Endpoint, + }, + jwtSecret: jwtSecret, } } @@ -64,13 +71,10 @@ func (ac *AuthController) Cli() gin.HandlerFunc { ac.authService.CliLogin(req) } - } func (ac *AuthController) Auth() gin.HandlerFunc { - return func(ctx *gin.Context) { - gitCode := ctx.Query("code") idempotencyID := ctx.Query("state") @@ -81,35 +85,33 @@ func (ac *AuthController) Auth() gin.HandlerFunc { if ctx.Request.Header.Get("Authorization") != "" { _, err := request.ParseFromRequest(ctx.Request, request.OAuth2Extractor, func(token *jwtlib.Token) (interface{}, error) { - b := []byte(mySuperSecretPassword) - return b, nil + return ac.jwtSecret, nil }) if err != nil { - ctx.AbortWithError(401, err) + ctx.AbortWithError(http.StatusUnauthorized, err) return } } if gitCode != "" { - if err := ac.validateAndConsumeState(idempotencyID); err != nil { + if err := ac.store.ValidateAndConsumeState(ctx.Request.Context(), idempotencyID); err != nil { ctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf("invalid oauth state: %v", err)) return } - tok, err := conf.Exchange(context.Background(), ctx.Query("code")) + tok, err := ac.oauthConfig.Exchange(context.Background(), ctx.Query("code")) if err != nil { - ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("Failed to do exchange: %v", err)) + ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("failed to do exchange: %v", err)) return } - client := github.NewClient(conf.Client(context.Background(), tok)) + client := github.NewClient(ac.oauthConfig.Client(context.Background(), tok)) user, _, err := client.Users.Get(context.Background(), "") - //client.Repositories.List(context.Background(), "", &github-auth.RepositoryListOptions{}) if err != nil { - ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("Failed to get user: %v", err)) + ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("failed to get user: %v", err)) return } - persysToken, _ := utils.GenerateToken(user) + persysToken, _ := utils.GenerateToken(user, ac.jwtSecret) data := models.UserInput{ Login: stringFromPointer(user.Login), @@ -138,70 +140,24 @@ func (ac *AuthController) Auth() gin.HandlerFunc { } func (ac *AuthController) LoginHandler() gin.HandlerFunc { - return func(c *gin.Context) { - state = utils.RandToken() - ac.storeOAuthState(state) - c.JSON(http.StatusOK, gin.H{"URL": GetLoginURL(state)}) - } - //ac.authService.SignInUser() - -} - -func (ac *AuthController) storeOAuthState(state string) { - if ac.sessionCollection == nil { - return - } - now := time.Now().UTC() - _, _ = ac.sessionCollection.UpdateOne(ac.ctx, - bson.M{"state": state}, - bson.M{"$set": bson.M{ - "state": state, - "created_at": now, - "expires_at": now.Add(10 * time.Minute), - "consumed": false, - }}, - ) -} - -func (ac *AuthController) validateAndConsumeState(state string) error { - if ac.sessionCollection == nil { - return nil - } - if state == "" { - return fmt.Errorf("empty state") - } - filter := bson.M{ - "state": state, - "consumed": false, - "expires_at": bson.M{"$gt": time.Now().UTC()}, - } - update := bson.M{"$set": bson.M{"consumed": true}} - res := ac.sessionCollection.FindOneAndUpdate(ac.ctx, filter, update) - if res.Err() != nil { - return res.Err() + state := utils.RandToken() + if err := ac.store.StoreOAuthState(c.Request.Context(), state, 10*time.Minute); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start login"}) + return + } + c.JSON(http.StatusOK, gin.H{"URL": ac.oauthConfig.AuthCodeURL(state)}) } - return nil } +// Setup finishes wiring the OAuth redirect URL and scopes now that +// they're known (both come from config, resolved once in main.go). +// ClientID/ClientSecret are already set from the constructor — Setup no +// longer re-reads config itself, which is what caused the double +// config.LoadConfig() call this replaces. func (ac *AuthController) Setup(redirectURL string, scopes []string) { - // IMPORTANT SECURITY ISSUE - c := Credentials{ - ClientID: cnf.GitHub.Auth.ClientID, - ClientSecret: cnf.GitHub.Auth.ClientSecret, - } - - conf = &oauth2.Config{ - ClientID: c.ClientID, - ClientSecret: c.ClientSecret, - RedirectURL: redirectURL, - Scopes: scopes, - Endpoint: oauth2gh.Endpoint, - } -} - -func GetLoginURL(state string) string { - return conf.AuthCodeURL(state) + ac.oauthConfig.RedirectURL = redirectURL + ac.oauthConfig.Scopes = scopes } func stringFromPointer(strPtr *string) (res string) { diff --git a/persys-gateway/controllers/automation.controller.go b/persys-gateway/controllers/automation.controller.go index 0fa84d7..fdcf056 100644 --- a/persys-gateway/controllers/automation.controller.go +++ b/persys-gateway/controllers/automation.controller.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/gin-gonic/gin" - automationv1 "github.com/persys-dev/persys-cloud/pkg/automation/automationv1" "github.com/persys-dev/persys-cloud/persys-gateway/services" + automationv1 "github.com/persys-dev/persys-cloud/pkg/automation/automationv1" ) type AutomationController struct { diff --git a/persys-gateway/controllers/github.controller.go b/persys-gateway/controllers/github.controller.go index d6b20f4..41aef4f 100644 --- a/persys-gateway/controllers/github.controller.go +++ b/persys-gateway/controllers/github.controller.go @@ -7,24 +7,23 @@ import ( "github.com/gin-gonic/gin" "github.com/persys-dev/persys-cloud/persys-gateway/config" "github.com/persys-dev/persys-cloud/persys-gateway/services" - "go.mongodb.org/mongo-driver/mongo" ) type GithubController struct { authService services.AuthService githubService services.GithubService - //userService services.UserService - ctx context.Context - collection *mongo.Collection - config *config.Config + ctx context.Context + config *config.Config } -func NewGithubController(authService services.AuthService, ctx context.Context, githubService services.GithubService, collection *mongo.Collection, cfg *config.Config) GithubController { +// NewGithubController previously took an unused *mongo.Collection +// parameter — dropped. Nothing in this controller ever read or wrote it; +// every handler goes through authService/githubService instead. +func NewGithubController(authService services.AuthService, ctx context.Context, githubService services.GithubService, cfg *config.Config) GithubController { return GithubController{ authService: authService, githubService: githubService, ctx: ctx, - collection: collection, config: cfg, } } diff --git a/persys-gateway/controllers/proto_helpers.go b/persys-gateway/controllers/proto_helpers.go new file mode 100644 index 0000000..25e563c --- /dev/null +++ b/persys-gateway/controllers/proto_helpers.go @@ -0,0 +1,38 @@ +package controllers + +import ( + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +func decodeProtoBody(ctx *gin.Context, msg proto.Message) bool { + body, err := io.ReadAll(ctx.Request.Body) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return false + } + if len(strings.TrimSpace(string(body))) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "request body is required"}) + return false + } + unmarshal := protojson.UnmarshalOptions{DiscardUnknown: true} + if err := unmarshal.Unmarshal(body, msg); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return false + } + return true +} + +func writeProtoJSON(ctx *gin.Context, status int, msg proto.Message) { + data, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(msg) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode response"}) + return + } + ctx.Data(status, "application/json", data) +} diff --git a/persys-gateway/controllers/scheduler.controller.go b/persys-gateway/controllers/scheduler.controller.go index 5a04e0f..e30962a 100644 --- a/persys-gateway/controllers/scheduler.controller.go +++ b/persys-gateway/controllers/scheduler.controller.go @@ -1,508 +1,94 @@ package controllers import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" "net/http" "sort" - "strconv" "strings" "time" "github.com/gin-gonic/gin" - "github.com/google/uuid" - controlv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/controlv1" - forgeryv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/forgeryv1" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/authn" "github.com/persys-dev/persys-cloud/persys-gateway/services" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" ) -type ProwController struct { - prowService *services.ProwService - authService services.AuthService - ctx context.Context -} - -func NewProwController(prowService *services.ProwService, authService services.AuthService, ctx context.Context) *ProwController { - return &ProwController{prowService: prowService, authService: authService, ctx: ctx} -} - -func (c *ProwController) determineAuthMethod(ctx *gin.Context) string { - if ctx.Request.TLS != nil && len(ctx.Request.TLS.PeerCertificates) > 0 { - return "mtls" - } - if c.authService.IsAuthenticated(ctx) { - return "oauth" - } - return "none" -} - -func (c *ProwController) validateAuthentication(ctx *gin.Context, authMethod string) bool { - switch authMethod { - case "mtls": - return true - case "oauth": - return c.authService.IsAuthenticated(ctx) - case "none": - return c.isPublicEndpoint(ctx.Request.URL.Path) - default: - return false - } -} - -func (c *ProwController) isPublicEndpoint(path string) bool { - publicPaths := []string{"/metrics", "/health", "/ready"} - for _, publicPath := range publicPaths { - if path == publicPath { - return true - } - } - return false -} - -func (c *ProwController) ListHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - c.ListWorkloadsHandler()(ctx) - } -} - -func (c *ProwController) ScheduleWorkloadHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - req := &controlv1.ApplyWorkloadRequest{} - if !decodeProtoBody(ctx, req) { - return - } - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - - resp, err := c.prowService.ApplyWorkload(ctx.Request.Context(), clusterID, sessionKey, workloadKey, req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) ListWorkloadsHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - req := &controlv1.ListWorkloadsRequest{Status: ctx.Query("status")} - - resp, err := c.prowService.ListWorkloads(ctx.Request.Context(), clusterID, sessionKey, workloadKey, req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) GetWorkloadHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - req := &controlv1.GetWorkloadRequest{WorkloadId: ctx.Param("id")} - - resp, err := c.prowService.GetWorkload(ctx.Request.Context(), clusterID, sessionKey, workloadKey, req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) DeleteWorkloadHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - req := &controlv1.DeleteWorkloadRequest{WorkloadId: ctx.Param("id")} - - resp, err := c.prowService.DeleteWorkload(ctx.Request.Context(), clusterID, sessionKey, workloadKey, req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) RetryWorkloadHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - req := &controlv1.RetryWorkloadRequest{WorkloadId: ctx.Param("id")} - - resp, err := c.prowService.RetryWorkload(ctx.Request.Context(), clusterID, sessionKey, workloadKey, req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) ListNodesHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - req := &controlv1.ListNodesRequest{Status: ctx.Query("status")} - - resp, err := c.prowService.ListNodes(ctx.Request.Context(), clusterID, sessionKey, workloadKey, req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) GetNodeHandler() gin.HandlerFunc { +// ClusterMetaController holds the handlers that were never RPC-shaped and +// so have nothing for grpcbridge to discover via reflection: health, +// list-clusters, get-cluster. Everything else that used to live on +// ProwController (workload/node CRUD, cluster metrics, forgery +// passthrough) is now served dynamically — see +// internal/router/bindings.go — since it's a straight AgentControl or +// ForgeryControl RPC with no logic of its own beyond what the dynamic +// bridge already does. +// +// Renamed from ProwController. Also dropped: ListHandler (was just an +// alias for ListWorkloadsHandler, redundant with the real /workloads +// route) and the determineAuthMethod/validateAuthentication/ +// isPublicEndpoint trio, which computed an auth decision that no handler +// in this file ever actually consulted — dead code pretending to be a +// safeguard. +type ClusterMetaController struct { + clusterControl *services.ClusterControlService + deploymentMode string + databaseEnabled bool +} + +func NewClusterMetaController(clusterControl *services.ClusterControlService, deploymentMode string, databaseEnabled bool) *ClusterMetaController { + return &ClusterMetaController{ + clusterControl: clusterControl, + deploymentMode: deploymentMode, + databaseEnabled: databaseEnabled, + } +} + +// Register implements router.Registrar. Mounted at the top level (not +// nested under /clusters/:cluster_id) since ListClustersHandler in +// particular has no single cluster to scope to. +func (c *ClusterMetaController) Register(rg *gin.RouterGroup, _ *authn.Middleware) { + // Health is intentionally unauthenticated — it's a liveness probe, + // not a customer-facing endpoint, in both self-hosted and managed + // deployments. + rg.GET("/health", c.HealthCheckHandler()) + rg.GET("/clusters", c.ListClustersHandler()) + rg.GET("/clusters/:cluster_id", c.GetClusterHandler()) +} + +func (c *ClusterMetaController) HealthCheckHandler() gin.HandlerFunc { return func(ctx *gin.Context) { - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - req := &controlv1.GetNodeRequest{NodeId: ctx.Param("id")} - - resp, err := c.prowService.GetNode(ctx.Request.Context(), clusterID, sessionKey, workloadKey, req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -type nodeReasonPayload struct { - Reason string `json:"reason"` -} - -type nodeTaintPayload struct { - Key string `json:"key" binding:"required"` - Value string `json:"value"` - Effect string `json:"effect"` -} - -type nodeLabelPayload struct { - Key string `json:"key" binding:"required"` - Value string `json:"value"` -} - -func (c *ProwController) DrainNodeHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var body nodeReasonPayload - _ = ctx.ShouldBindJSON(&body) - resp, err := c.prowService.DrainNode(ctx.Request.Context(), c.resolveClusterID(ctx), c.resolveSessionKey(ctx), c.resolveWorkloadKey(ctx), &controlv1.DrainNodeRequest{NodeId: ctx.Param("id"), Reason: body.Reason}) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) UndrainNodeHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var body nodeReasonPayload - _ = ctx.ShouldBindJSON(&body) - resp, err := c.prowService.UndrainNode(ctx.Request.Context(), c.resolveClusterID(ctx), c.resolveSessionKey(ctx), c.resolveWorkloadKey(ctx), &controlv1.UndrainNodeRequest{NodeId: ctx.Param("id"), Reason: body.Reason}) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) TaintNodeHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var body nodeTaintPayload - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid taint payload"}) - return - } - req := &controlv1.TaintNodeRequest{NodeId: ctx.Param("id"), Taint: &controlv1.NodeTaint{Key: body.Key, Value: body.Value, Effect: body.Effect}} - resp, err := c.prowService.TaintNode(ctx.Request.Context(), c.resolveClusterID(ctx), c.resolveSessionKey(ctx), c.resolveWorkloadKey(ctx), req) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) UntaintNodeHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var body nodeTaintPayload - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid taint payload"}) - return - } - resp, err := c.prowService.UntaintNode(ctx.Request.Context(), c.resolveClusterID(ctx), c.resolveSessionKey(ctx), c.resolveWorkloadKey(ctx), &controlv1.UntaintNodeRequest{NodeId: ctx.Param("id"), Key: body.Key, Effect: body.Effect}) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) SetNodeLabelHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var body nodeLabelPayload - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid label payload"}) - return - } - resp, err := c.prowService.SetNodeLabel(ctx.Request.Context(), c.resolveClusterID(ctx), c.resolveSessionKey(ctx), c.resolveWorkloadKey(ctx), &controlv1.SetNodeLabelRequest{NodeId: ctx.Param("id"), Key: body.Key, Value: body.Value}) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) DeleteNodeLabelHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var body nodeLabelPayload - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid label payload"}) - return - } - resp, err := c.prowService.DeleteNodeLabel(ctx.Request.Context(), c.resolveClusterID(ctx), c.resolveSessionKey(ctx), c.resolveWorkloadKey(ctx), &controlv1.DeleteNodeLabelRequest{NodeId: ctx.Param("id"), Key: body.Key}) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) ClusterMetricsHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - clusterID := c.resolveClusterID(ctx) - sessionKey := c.resolveSessionKey(ctx) - workloadKey := c.resolveWorkloadKey(ctx) - - resp, err := c.prowService.GetClusterSummary(ctx.Request.Context(), clusterID, sessionKey, workloadKey, &controlv1.GetClusterSummaryRequest{}) - if err != nil { - c.writeProxyError(ctx, err) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -type triggerBuildPayload struct { - ProjectName string `json:"project_name" binding:"required"` - Repository string `json:"repository"` - ClusterID string `json:"cluster_id"` - Ref string `json:"ref"` - CommitSHA string `json:"commit_sha"` - Sender string `json:"sender"` - Mode string `json:"mode"` - EventType string `json:"event_type"` -} - -func (c *ProwController) TriggerBuildHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var reqBody triggerBuildPayload - if err := ctx.ShouldBindJSON(&reqBody); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid build request payload"}) - return - } - clusterID := c.resolveClusterID(ctx) - if clusterID == "" { - clusterID = reqBody.ClusterID - } - - resp, err := c.prowService.TriggerBuild(ctx.Request.Context(), &forgeryv1.TriggerBuildRequest{ - ProjectName: strings.TrimSpace(reqBody.ProjectName), - Repository: strings.TrimSpace(reqBody.Repository), - ClusterId: strings.TrimSpace(clusterID), - Ref: strings.TrimSpace(reqBody.Ref), - CommitSha: strings.TrimSpace(reqBody.CommitSHA), - Sender: strings.TrimSpace(reqBody.Sender), - Mode: strings.TrimSpace(reqBody.Mode), - EventType: strings.TrimSpace(reqBody.EventType), - }) - if err != nil { - ctx.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) ListPipelineStatusHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - limit := uint32(50) - if rawLimit := strings.TrimSpace(ctx.Query("limit")); rawLimit != "" { - if n, err := strconv.Atoi(rawLimit); err == nil && n > 0 { - limit = uint32(n) - } - } - resp, err := c.prowService.ListPipelineStatus(ctx.Request.Context(), &forgeryv1.ListPipelineStatusRequest{ - DeliveryId: strings.TrimSpace(ctx.Query("delivery_id")), - Repository: strings.TrimSpace(ctx.Query("repository")), - Limit: limit, - }) - if err != nil { - ctx.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -type upsertProjectPayload struct { - Name string `json:"name" binding:"required"` - RepoURL string `json:"repo_url" binding:"required"` - DefaultBranch string `json:"default_branch"` - ClusterID string `json:"cluster_id"` - BuildType string `json:"build_type"` - BuildMode string `json:"build_mode"` - Strategy string `json:"strategy"` - NexusRepo string `json:"nexus_repo"` - PipelineYAML string `json:"pipeline_yaml"` - AutoDeploy bool `json:"auto_deploy"` - ImageName string `json:"image_name"` -} - -func (c *ProwController) UpsertProjectHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var reqBody upsertProjectPayload - if err := ctx.ShouldBindJSON(&reqBody); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid project payload"}) - return - } - clusterID := c.resolveClusterID(ctx) - if clusterID == "" { - clusterID = reqBody.ClusterID - } - - resp, err := c.prowService.UpsertProject(ctx.Request.Context(), &forgeryv1.UpsertProjectRequest{ - Name: strings.TrimSpace(reqBody.Name), - RepoUrl: strings.TrimSpace(reqBody.RepoURL), - DefaultBranch: strings.TrimSpace(reqBody.DefaultBranch), - ClusterId: strings.TrimSpace(clusterID), - BuildType: strings.TrimSpace(reqBody.BuildType), - BuildMode: strings.TrimSpace(reqBody.BuildMode), - Strategy: strings.TrimSpace(reqBody.Strategy), - NexusRepo: strings.TrimSpace(reqBody.NexusRepo), - PipelineYaml: reqBody.PipelineYAML, - AutoDeploy: reqBody.AutoDeploy, - ImageName: strings.TrimSpace(reqBody.ImageName), - }) - if err != nil { - ctx.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -type webhookTestPayload struct { - DeliveryID string `json:"delivery_id"` - EventType string `json:"event_type"` - Repository string `json:"repository" binding:"required"` - ClusterID string `json:"cluster_id"` - Sender string `json:"sender"` - Ref string `json:"ref"` - Before string `json:"before"` - After string `json:"after"` - Payload any `json:"payload"` -} - -func (c *ProwController) TestWebhookHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - var reqBody webhookTestPayload - if err := ctx.ShouldBindJSON(&reqBody); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook test payload"}) - return - } - clusterID := c.resolveClusterID(ctx) - if clusterID == "" { - clusterID = reqBody.ClusterID - } - - if strings.TrimSpace(reqBody.DeliveryID) == "" { - reqBody.DeliveryID = uuid.NewString() - } - if strings.TrimSpace(reqBody.EventType) == "" { - reqBody.EventType = "push" - } - - payloadJSON := "{}" - if reqBody.Payload != nil { - if marshaled, err := json.Marshal(reqBody.Payload); err == nil { - payloadJSON = string(marshaled) - } - } - - resp, err := c.prowService.ForwardWebhookTest(ctx.Request.Context(), &forgeryv1.ForwardWebhookRequest{ - DeliveryId: reqBody.DeliveryID, - EventType: strings.TrimSpace(reqBody.EventType), - Repository: strings.TrimSpace(reqBody.Repository), - ClusterId: strings.TrimSpace(clusterID), - Sender: strings.TrimSpace(reqBody.Sender), - Ref: strings.TrimSpace(reqBody.Ref), - Before: strings.TrimSpace(reqBody.Before), - After: strings.TrimSpace(reqBody.After), - PayloadJson: payloadJSON, - Verified: true, + ctx.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "service": "persys-gateway", + "deployment_mode": c.deploymentMode, + "database_enabled": c.databaseEnabled, + "legacy_proxy_enabled": c.clusterControl.IsProxyEnabled(), + "legacy_scheduler_addr": c.clusterControl.GetSchedulerAddress(), }) - if err != nil { - ctx.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - writeProtoJSON(ctx, http.StatusOK, resp) - } -} - -func (c *ProwController) HealthCheckHandler() gin.HandlerFunc { - return func(ctx *gin.Context) { - ctx.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "persys-gateway", "prow_proxy_enabled": c.prowService.IsProxyEnabled(), "prow_scheduler": c.prowService.GetSchedulerAddress()}) } } -func (c *ProwController) ListClustersHandler() gin.HandlerFunc { +func (c *ClusterMetaController) ListClustersHandler() gin.HandlerFunc { return func(ctx *gin.Context) { - clusters := c.prowService.SnapshotClusters() + clusters := c.clusterControl.SnapshotClusters() sort.SliceStable(clusters, func(i, j int) bool { return clusters[i].ID < clusters[j].ID }) ctx.JSON(http.StatusOK, gin.H{ - "default_cluster_id": c.prowService.DefaultClusterID(), + "default_cluster_id": c.clusterControl.DefaultClusterID(), "clusters": buildClusterViews(clusters), }) } } -func (c *ProwController) GetClusterHandler() gin.HandlerFunc { +func (c *ClusterMetaController) GetClusterHandler() gin.HandlerFunc { return func(ctx *gin.Context) { clusterID := strings.TrimSpace(ctx.Param("cluster_id")) if clusterID == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": "cluster_id is required"}) return } - for _, cluster := range c.prowService.SnapshotClusters() { + for _, cluster := range c.clusterControl.SnapshotClusters() { if cluster.ID != clusterID { continue } ctx.JSON(http.StatusOK, gin.H{ - "default_cluster_id": c.prowService.DefaultClusterID(), + "default_cluster_id": c.clusterControl.DefaultClusterID(), "cluster": buildClusterView(cluster), }) return @@ -511,86 +97,6 @@ func (c *ProwController) GetClusterHandler() gin.HandlerFunc { } } -func (c *ProwController) resolveClusterID(ctx *gin.Context) string { - if clusterID := strings.TrimSpace(ctx.Param("cluster_id")); clusterID != "" { - return clusterID - } - if clusterID := strings.TrimSpace(ctx.GetHeader("X-Persys-Cluster-ID")); clusterID != "" { - return clusterID - } - if clusterID := strings.TrimSpace(ctx.Query("cluster_id")); clusterID != "" { - return clusterID - } - return "" -} - -func (c *ProwController) resolveSessionKey(ctx *gin.Context) string { - if s := strings.TrimSpace(ctx.GetHeader("X-Persys-Session")); s != "" { - return s - } - if cookie, err := ctx.Cookie("persys_session"); err == nil && strings.TrimSpace(cookie) != "" { - return cookie - } - authz := strings.TrimSpace(ctx.GetHeader("Authorization")) - if authz == "" { - return strings.TrimSpace(ctx.ClientIP()) - } - sum := sha256.Sum256([]byte(authz)) - return hex.EncodeToString(sum[:]) -} - -func (c *ProwController) resolveWorkloadKey(ctx *gin.Context) string { - if key := strings.TrimSpace(ctx.GetHeader("X-Persys-Workload-Key")); key != "" { - return key - } - if id := strings.TrimSpace(ctx.Param("id")); id != "" { - return id - } - if wid := strings.TrimSpace(ctx.Query("workload_id")); wid != "" { - return wid - } - return ctx.Request.URL.Path -} - -func (c *ProwController) writeProxyError(ctx *gin.Context, err error) { - if services.IsUnknownCluster(err) { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if services.IsSchedulerUnavailable(err) { - ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "no healthy scheduler available"}) - return - } - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -} - -func decodeProtoBody(ctx *gin.Context, msg proto.Message) bool { - body, err := io.ReadAll(ctx.Request.Body) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) - return false - } - if len(strings.TrimSpace(string(body))) == 0 { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "request body is required"}) - return false - } - unmarshal := protojson.UnmarshalOptions{DiscardUnknown: true} - if err := unmarshal.Unmarshal(body, msg); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) - return false - } - return true -} - -func writeProtoJSON(ctx *gin.Context, status int, msg proto.Message) { - data, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(msg) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode response"}) - return - } - ctx.Data(status, "application/json", data) -} - func buildClusterViews(clusters []services.Cluster) []gin.H { out := make([]gin.H, 0, len(clusters)) for _, cluster := range clusters { diff --git a/persys-gateway/go.mod b/persys-gateway/go.mod old mode 100755 new mode 100644 index 3332c87..13635e3 --- a/persys-gateway/go.mod +++ b/persys-gateway/go.mod @@ -8,13 +8,11 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/golang/glog v1.2.5 github.com/google/go-github v17.0.0+incompatible - github.com/google/uuid v1.6.0 - github.com/hashicorp/vault/api v1.22.0 + github.com/jackc/pgx/v5 v5.6.0 github.com/persys-dev/persys-cloud/pkg v0.0.0-20260701205454-9cb94822d71f github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 github.com/zsais/go-gin-prometheus v0.1.0 - go.mongodb.org/mongo-driver v1.13.1 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.46.1 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 @@ -45,8 +43,8 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.16.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang/snappy v0.0.4 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -57,8 +55,11 @@ require ( github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.22.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -66,7 +67,6 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -78,10 +78,6 @@ require ( github.com/ryanuber/go-glob v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect diff --git a/persys-gateway/go.sum b/persys-gateway/go.sum index c98d5b8..dd753f3 100755 --- a/persys-gateway/go.sum +++ b/persys-gateway/go.sum @@ -58,9 +58,6 @@ github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -96,9 +93,16 @@ github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -129,9 +133,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -171,21 +172,9 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk= -github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zsais/go-gin-prometheus v0.1.0 h1:bkLv1XCdzqVgQ36ScgRi09MA2UC1t3tAB6nsfErsGO4= github.com/zsais/go-gin-prometheus v0.1.0/go.mod h1:Slirjzuz8uM8Cw0jmPNqbneoqcUtY2GGjn2bEd4NRLY= -go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= -go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.46.1 h1:mMv2jG58h6ZI5t5S9QCVGdzCmAsTakMa3oxVgpSD44g= @@ -226,50 +215,33 @@ golang.org/x/arch v0.6.0 h1:S0JTfE48HbRj80+4tbvZDYsJ3tGv6BUU3XxyZ7CirAc= golang.org/x/arch v0.6.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -278,7 +250,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/persys-gateway/internal/authn/middleware.go b/persys-gateway/internal/authn/middleware.go new file mode 100644 index 0000000..6722dae --- /dev/null +++ b/persys-gateway/internal/authn/middleware.go @@ -0,0 +1,169 @@ +// Package authn provides the auth middleware used by the dynamic RPC +// bridge and other new-style routes. It deliberately verifies the exact +// same tokens utils.GenerateToken issues (same secret, same dgrijalva/ +// jwt-go library, same "UserID" claim) rather than introducing a second +// JWT library or claims schema — the gateway has exactly one session +// token format, used everywhere a bearer token is accepted. +// +// This does NOT replace controllers.AuthController.Auth(), which handles +// the OAuth exchange itself (GitHub code -> token issuance). It replaces +// the *verification* half for routes that don't need the OAuth dance — +// cluster control, forgery, automation — with one addition: +// RequireClusterOwnership, needed once cluster-per-tenant ownership +// exists (see db/migrations when that lands; not wired yet). +package authn + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + jwtlib "github.com/dgrijalva/jwt-go" + "github.com/dgrijalva/jwt-go/request" + "github.com/gin-gonic/gin" +) + +type contextKey string + +const userIDKey contextKey = "persys_user_id" + +type Middleware struct { + secret []byte +} + +// New builds the middleware from the same secret used to sign tokens +// (cnf.App.JWTSecret) — see config.Config.App.JWTSecret for how that +// secret is sourced (env var, never hardcoded, fails fast if unset in +// managed mode). +func New(secret []byte) *Middleware { + return &Middleware{secret: secret} +} + +func (m *Middleware) parseAndVerify(r *http.Request) (userID string, err error) { + token, err := request.ParseFromRequest(r, request.OAuth2Extractor, func(t *jwtlib.Token) (interface{}, error) { + return m.secret, nil + }) + if err != nil { + return "", err + } + claims, ok := token.Claims.(jwtlib.MapClaims) + if !ok || !token.Valid { + return "", errors.New("invalid token claims") + } + raw, ok := claims["UserID"] + if !ok { + return "", errors.New("token missing UserID claim") + } + switch v := raw.(type) { + case float64: + return strconv.FormatInt(int64(v), 10), nil + case string: + return v, nil + default: + return "", fmt.Errorf("unexpected UserID claim type %T", raw) + } +} + +// RequireUser rejects any request without a valid, non-expired user JWT +// and attaches the verified user ID to the context. +func (m *Middleware) RequireUser() gin.HandlerFunc { + return func(c *gin.Context) { + userID, err := m.parseAndVerify(c.Request) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + c.Set(string(userIDKey), userID) + c.Next() + } +} + +// RequireMTLS rejects any request that didn't present a verified client +// certificate on this connection. Used for internal service-to-service +// routes (persysctl node/cluster ops) that carry no user identity. +func (m *Middleware) RequireMTLS() gin.HandlerFunc { + return func(c *gin.Context) { + if !hasVerifiedClientCert(c.Request) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "client certificate required"}) + return + } + c.Next() + } +} + +// RequireMTLSOrUser accepts either trust path: a verified client cert +// (service-to-service) or a valid user JWT (managed-mode customer +// traffic). +func (m *Middleware) RequireMTLSOrUser() gin.HandlerFunc { + return func(c *gin.Context) { + if hasVerifiedClientCert(c.Request) { + c.Next() + return + } + userID, err := m.parseAndVerify(c.Request) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "client certificate or bearer token required"}) + return + } + c.Set(string(userIDKey), userID) + c.Next() + } +} + +// ClusterOwnership answers "does this user own this cluster?" — the +// entire multi-tenancy surface for the managed offering, checked once at +// the gateway boundary rather than threaded through every downstream +// service. Not wired into main.go yet (needs the cluster_owners table), +// but the middleware is ready for when it is. +type ClusterOwnership interface { + Owns(ctx context.Context, userID, clusterID string) (bool, error) +} + +// RequireClusterOwnership must run after RequireUser (or +// RequireMTLSOrUser) on any route with a :cluster_id path param +// representing a customer-owned cluster. In self-hosted mode this +// middleware is simply never attached, so it has zero cost and zero +// relevance there. +func (m *Middleware) RequireClusterOwnership(store ClusterOwnership) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := UserID(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + clusterID := c.Param("cluster_id") + if clusterID == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "cluster_id required"}) + return + } + owns, err := store.Owns(c.Request.Context(), userID, clusterID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "ownership check failed"}) + return + } + if !owns { + // 404, not 403 — don't confirm the cluster_id exists to a + // caller who doesn't own it. + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "cluster not found"}) + return + } + c.Next() + } +} + +// UserID returns the verified user ID attached by RequireUser or +// RequireMTLSOrUser, if any. +func UserID(c *gin.Context) (string, bool) { + v, ok := c.Get(string(userIDKey)) + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok && s != "" +} + +func hasVerifiedClientCert(r *http.Request) bool { + return r.TLS != nil && len(r.TLS.VerifiedChains) > 0 && len(r.TLS.PeerCertificates) > 0 +} diff --git a/persys-gateway/internal/catalog/catalog.go b/persys-gateway/internal/catalog/catalog.go new file mode 100644 index 0000000..78e5000 --- /dev/null +++ b/persys-gateway/internal/catalog/catalog.go @@ -0,0 +1,145 @@ +// Package catalog implements a config-driven service registry for the +// gateway. Instead of every backend service requiring a new controller, +// route file, and wiring block in cmd/main.go, HTTP-proxied services are +// described declaratively and registered generically at startup. +// +// Services that need typed proto translation (scheduler, forgery, +// automation) don't belong here — they keep dedicated controllers, but +// self-register into the router via the Registrar interface in router.go +// instead of being named explicitly in main.go. +package catalog + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/persys-dev/persys-cloud/persys-gateway/config" + "gopkg.in/yaml.v3" +) + +// AuthMode controls what the gateway requires before proxying a request. +type AuthMode string + +const ( + // AuthNone: no auth check. Only ever appropriate for truly public + // endpoints (health checks, the GitHub webhook receiver, which + // authenticates via HMAC signature instead of a bearer token). + AuthNone AuthMode = "none" + + // AuthMTLS: caller must present a client cert on the mTLS listener. + // Used for internal service-to-service calls (persysctl node ops, + // scheduler control plane). No user identity is required or attached. + // This is also what AuthUser downgrades to in self-hosted mode — see + // Resolve. + AuthMTLS AuthMode = "mtls" + + // AuthUser declares "this route touches a customer-owned resource." + // It does NOT mean a JWT is always required — see Resolve. In + // self-hosted deployments there's no tenant to protect against, so it + // resolves to AuthMTLS. In managed deployments it resolves to a real + // JWT requirement. + AuthUser AuthMode = "user" + + // AuthEither: mTLS OR a user JWT satisfies the request. Rare — + // mainly for cluster-registration endpoints that persysctl calls + // over mTLS during bootstrap, and that a managed dashboard might also + // call on a user's behalf. + AuthEither AuthMode = "either" +) + +// DeploymentMode is an alias for config.DeploymentMode — the deployment +// toggle has exactly one definition (config/config.go), used by +// config validation (fail fast on missing JWT secret in managed mode), +// the catalog's auth resolution, and grpcbridge binding auth. Defining it +// twice would let the two silently drift. +type DeploymentMode = config.DeploymentMode + +const ( + SelfHosted = config.DeploymentSelfHosted + Managed = config.DeploymentManaged +) + +// Resolve maps a route's declared intent onto what's actually enforced +// for the given deployment mode. +func (a AuthMode) Resolve(mode DeploymentMode) AuthMode { + if a == AuthUser && mode == SelfHosted { + return AuthMTLS + } + if a == AuthEither && mode == SelfHosted { + return AuthMTLS + } + return a +} + +// Service describes one backend that the gateway proxies HTTP requests to. +type Service struct { + // Name is a unique identifier, used in logs/metrics. + Name string `yaml:"name"` + + // PathPrefix is the inbound prefix this service owns, e.g. "/ai". + // All requests under this prefix are proxied. + PathPrefix string `yaml:"path_prefix"` + + // UpstreamAddr is the base URL of the backend, e.g. + // "http://persys-intelligence:8093". + UpstreamAddr string `yaml:"upstream_addr"` + + // StripPrefix, if true, removes PathPrefix before forwarding, so + // "/ai/query" -> "/query" upstream. If false, the full path is kept. + StripPrefix bool `yaml:"strip_prefix"` + + // Auth selects the trust model required for this service's routes. + Auth AuthMode `yaml:"auth"` + + // Timeout bounds the outbound request. Defaults to 10s. + Timeout time.Duration `yaml:"timeout"` + + // Enabled lets an entry be present but disabled without deleting it. + Enabled bool `yaml:"enabled"` +} + +type Catalog struct { + Services []Service `yaml:"services"` +} + +func Load(path string) (*Catalog, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read service catalog %q: %w", path, err) + } + var c Catalog + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parse service catalog %q: %w", path, err) + } + if err := c.validate(); err != nil { + return nil, err + } + return &c, nil +} + +func (c *Catalog) validate() error { + seen := map[string]bool{} + for _, svc := range c.Services { + if strings.TrimSpace(svc.Name) == "" { + return fmt.Errorf("service catalog: entry missing name") + } + if seen[svc.Name] { + return fmt.Errorf("service catalog: duplicate service name %q", svc.Name) + } + seen[svc.Name] = true + if strings.TrimSpace(svc.PathPrefix) == "" { + return fmt.Errorf("service catalog: %s missing path_prefix", svc.Name) + } + if svc.Enabled && strings.TrimSpace(svc.UpstreamAddr) == "" { + return fmt.Errorf("service catalog: %s missing upstream_addr", svc.Name) + } + switch svc.Auth { + case AuthNone, AuthMTLS, AuthUser, AuthEither: + default: + return fmt.Errorf("service catalog: %s has invalid auth mode %q", svc.Name, svc.Auth) + } + } + return nil +} diff --git a/persys-gateway/internal/certmanager/vault.go b/persys-gateway/internal/certmanager/vault.go deleted file mode 100644 index 4e1d6f2..0000000 --- a/persys-gateway/internal/certmanager/vault.go +++ /dev/null @@ -1,607 +0,0 @@ -package certmanager - -import ( - "context" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "net" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "time" - - vault "github.com/hashicorp/vault/api" - "github.com/persys-dev/persys-cloud/persys-gateway/config" - "github.com/sirupsen/logrus" -) - -const ( - rotationFractionNumerator = 80 - rotationFractionDenominator = 100 - minRotationWait = 30 * time.Second -) - -type Config struct { - TLSEnabled bool - - TLSCertPath string - TLSKeyPath string - TLSCAPath string - - VaultEnabled bool - VaultAddr string - VaultAuthMethod string - VaultToken string - VaultAppRoleID string - VaultAppSecretID string - VaultPKIMount string - VaultPKIRole string - VaultCertTTL time.Duration - VaultServiceName string - VaultServiceDomain string - VaultRetryInterval time.Duration - - BindHost string -} - -type Manager struct { - cfg Config - logger *logrus.Entry - - mu sync.RWMutex - current certMeta -} - -type certMeta struct { - notBefore time.Time - notAfter time.Time -} - -func NewManager(cfg Config, logger *logrus.Logger) *Manager { - return &Manager{ - cfg: cfg, - logger: logger.WithField("component", "vault-cert-manager"), - } -} - -func NewFromConfig(cfg *config.Config, logger *logrus.Logger) (*Manager, error) { - certTTL:= cfg.Vault.CertTTL - retryInterval := cfg.Vault.RetryInterval - - return NewManager(Config{ - TLSEnabled: cfg.TLS.Enabled, - TLSCertPath: cfg.TLS.CertPath, - TLSKeyPath: cfg.TLS.KeyPath, - TLSCAPath: cfg.TLS.CAPath, - VaultEnabled: cfg.Vault.Enabled, - VaultAddr: cfg.Vault.Addr, - VaultAuthMethod: cfg.Vault.AuthMethod, - VaultToken: cfg.Vault.Token, - VaultAppRoleID: cfg.Vault.AppRoleID, - VaultAppSecretID: cfg.Vault.AppSecretID, - VaultPKIMount: cfg.Vault.PKIMount, - VaultPKIRole: cfg.Vault.PKIRole, - VaultCertTTL: certTTL, - VaultServiceName: cfg.Vault.ServiceName, - VaultServiceDomain: cfg.Vault.ServiceDomain, - VaultRetryInterval: retryInterval, - BindHost: cfg.Vault.BindHost, - }, logger), nil -} - -func (m *Manager) Validate() error { - if !m.cfg.TLSEnabled { - return nil - } - if !m.cfg.VaultEnabled { - return nil - } - if strings.TrimSpace(m.cfg.VaultAddr) == "" { - return fmt.Errorf("vault is enabled but PERSYS_VAULT_ADDR is empty") - } - if strings.TrimSpace(m.cfg.VaultPKIMount) == "" || strings.TrimSpace(m.cfg.VaultPKIRole) == "" { - return fmt.Errorf("vault is enabled but PKI mount/role is not configured") - } - switch strings.ToLower(strings.TrimSpace(m.cfg.VaultAuthMethod)) { - case "token": - if strings.TrimSpace(m.cfg.VaultToken) == "" { - return fmt.Errorf("vault token auth selected but PERSYS_VAULT_TOKEN is empty") - } - case "approle": - if strings.TrimSpace(m.cfg.VaultAppRoleID) == "" || strings.TrimSpace(m.cfg.VaultAppSecretID) == "" { - return fmt.Errorf("vault approle auth selected but role_id/secret_id is missing") - } - default: - return fmt.Errorf("unsupported vault auth method %q (expected token|approle)", m.cfg.VaultAuthMethod) - } - if m.cfg.VaultCertTTL <= 0 { - return fmt.Errorf("vault cert TTL must be positive") - } - if m.cfg.VaultRetryInterval <= 0 { - return fmt.Errorf("vault retry interval must be positive") - } - return nil -} - -func (m *Manager) Start(ctx context.Context) error { - if !m.cfg.TLSEnabled { - return nil - } - if !m.cfg.VaultEnabled { - m.logger.Info("Vault cert manager disabled; using manual certificate files") - return nil - } - if err := m.Validate(); err != nil { - return err - } - if existingMeta, ok := m.loadExistingCertMeta(); ok { - m.mu.Lock() - m.current = existingMeta - m.mu.Unlock() - m.logger.WithFields(logrus.Fields{ - "not_before": existingMeta.notBefore.UTC().Format(time.RFC3339), - "not_after": existingMeta.notAfter.UTC().Format(time.RFC3339), - }).Info("Using existing valid certificate from disk") - go m.rotationLoop(ctx) - return nil - } - - cli, err := m.newVaultClient() - if err != nil { - if m.manualCertAvailable() { - m.logger.WithError(err).Warn("Vault unavailable on startup, falling back to manual certificates") - go m.recoveryLoop(ctx) - return nil - } - return fmt.Errorf("vault unavailable and no manual cert fallback found: %w", err) - } - - if err := m.issueAndPersist(ctx, cli); err != nil { - if m.manualCertAvailable() { - m.logger.WithError(err).Warn("Vault certificate issuance failed, using manual certificates") - go m.recoveryLoop(ctx) - return nil - } - return fmt.Errorf("vault issuance failed and no manual cert fallback found: %w", err) - } - - go m.rotationLoop(ctx) - return nil -} - -func (m *Manager) rotationLoop(ctx context.Context) { - for { - renewAt := m.nextRenewAt() - wait := time.Until(renewAt) - if wait < minRotationWait { - wait = minRotationWait - } - - m.logger.WithField("next_rotation", renewAt.UTC().Format(time.RFC3339)).Info("Next certificate rotation scheduled") - - select { - case <-ctx.Done(): - return - case <-time.After(wait): - } - - cli, err := m.newVaultClient() - if err != nil { - m.logger.WithError(err).Warn("Vault not reachable during rotation window; retrying later") - continue - } - if err := m.issueAndPersist(ctx, cli); err != nil { - m.logger.WithError(err).Warn("Certificate rotation failed; retrying later") - continue - } - } -} - -func (m *Manager) recoveryLoop(ctx context.Context) { - ticker := time.NewTicker(m.cfg.VaultRetryInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - - cli, err := m.newVaultClient() - if err != nil { - m.logger.WithError(err).Debug("Vault still unavailable while running on fallback certs") - continue - } - if err := m.issueAndPersist(ctx, cli); err != nil { - m.logger.WithError(err).Warn("Vault recovered but certificate issuance still failing") - continue - } - - m.logger.Info("Vault certificate provisioning recovered; enabling rotation loop") - go m.rotationLoop(ctx) - return - } -} - -func (m *Manager) newVaultClient() (*vault.Client, error) { - conf := vault.DefaultConfig() - conf.Address = m.cfg.VaultAddr - - client, err := vault.NewClient(conf) - if err != nil { - return nil, err - } - - switch strings.ToLower(strings.TrimSpace(m.cfg.VaultAuthMethod)) { - case "token": - client.SetToken(m.cfg.VaultToken) - if _, err := client.Auth().Token().LookupSelf(); err != nil { - return nil, fmt.Errorf("token auth validation failed: %w", err) - } - case "approle": - secret, err := client.Logical().Write("auth/approle/login", map[string]interface{}{ - "role_id": m.cfg.VaultAppRoleID, - "secret_id": m.cfg.VaultAppSecretID, - }) - if err != nil { - return nil, fmt.Errorf("approle login failed: %w", err) - } - if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" { - return nil, errors.New("approle login returned empty client token") - } - client.SetToken(secret.Auth.ClientToken) - default: - return nil, fmt.Errorf("unsupported vault auth method: %s", m.cfg.VaultAuthMethod) - } - - return client, nil -} - -func (m *Manager) issueAndPersist(ctx context.Context, client *vault.Client) error { - dnsSANs, ipSANs := m.detectSANs() - payload := map[string]interface{}{ - "common_name": m.cfg.VaultServiceName, - "ttl": m.cfg.VaultCertTTL.String(), - } - if len(dnsSANs) > 0 { - payload["alt_names"] = strings.Join(dnsSANs, ",") - } - if len(ipSANs) > 0 { - payload["ip_sans"] = strings.Join(ipSANs, ",") - } - - path := fmt.Sprintf("%s/issue/%s", strings.Trim(m.cfg.VaultPKIMount, "/"), m.cfg.VaultPKIRole) - secret, err := client.Logical().WriteWithContext(ctx, path, payload) - if err != nil { - return err - } - if secret == nil || secret.Data == nil { - return errors.New("empty response from vault issue endpoint") - } - - certPEM := asString(secret.Data["certificate"]) - keyPEM := asString(secret.Data["private_key"]) - issuingCA := asString(secret.Data["issuing_ca"]) - caChain := parseCAChain(secret.Data["ca_chain"]) - - if certPEM == "" || keyPEM == "" { - return errors.New("vault response missing certificate or private key") - } - - combinedCA := combineCA(issuingCA, caChain) - if combinedCA == "" { - return errors.New("vault response missing CA chain") - } - - notBefore, notAfter, err := certValidity(certPEM, keyPEM) - if err != nil { - return err - } - - if err := writeCertBundleAtomic(m.cfg.TLSCertPath, certPEM, m.cfg.TLSKeyPath, keyPEM, m.cfg.TLSCAPath, combinedCA); err != nil { - return err - } - - m.mu.Lock() - m.current = certMeta{ - notBefore: notBefore, - notAfter: notAfter, - } - m.mu.Unlock() - - m.logger.WithFields(logrus.Fields{ - "not_before": notBefore.UTC().Format(time.RFC3339), - "not_after": notAfter.UTC().Format(time.RFC3339), - "dns_sans": strings.Join(dnsSANs, ","), - "ip_sans": strings.Join(ipSANs, ","), - }).Info("Issued and installed certificate from Vault") - - return nil -} - -func (m *Manager) detectSANs() ([]string, []string) { - dnsSet := map[string]struct{}{} - ipSet := map[string]struct{}{} - addDNS := func(s string) { - s = strings.TrimSpace(strings.ToLower(s)) - if s != "" { - dnsSet[s] = struct{}{} - } - } - addIP := func(s string) { - s = strings.TrimSpace(s) - if ip := net.ParseIP(s); ip != nil { - ipSet[ip.String()] = struct{}{} - } - } - - service := strings.TrimSpace(m.cfg.VaultServiceName) - if service == "" { - service = "persys-gateway" - } - addDNS(service) - addDNS("localhost") - addIP("127.0.0.1") - addIP("::1") - - if host, err := os.Hostname(); err == nil { - addDNS(host) - } - - domain := strings.Trim(strings.ToLower(m.cfg.VaultServiceDomain), ".") - if domain != "" { - addDNS(service + "." + domain) - if host, err := os.Hostname(); err == nil { - short := strings.Split(host, ".")[0] - addDNS(short + "." + domain) - } - } - - if bindHost := strings.TrimSpace(m.cfg.BindHost); bindHost != "" && bindHost != "0.0.0.0" { - if ip := net.ParseIP(bindHost); ip != nil { - addIP(ip.String()) - } else { - addDNS(bindHost) - } - } - - if u, err := url.Parse(m.cfg.VaultAddr); err == nil { - host := u.Hostname() - if ip := net.ParseIP(host); ip != nil { - addIP(ip.String()) - } - } - - if addrs, err := net.InterfaceAddrs(); err == nil { - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() { - continue - } - addIP(ipNet.IP.String()) - } - } - - dnsSANs := make([]string, 0, len(dnsSet)) - for s := range dnsSet { - dnsSANs = append(dnsSANs, s) - } - ipSANs := make([]string, 0, len(ipSet)) - for s := range ipSet { - ipSANs = append(ipSANs, s) - } - return dnsSANs, ipSANs -} - -func (m *Manager) manualCertAvailable() bool { - if _, err := tls.LoadX509KeyPair(m.cfg.TLSCertPath, m.cfg.TLSKeyPath); err != nil { - return false - } - caPEM, err := os.ReadFile(m.cfg.TLSCAPath) - if err != nil { - return false - } - pool := x509.NewCertPool() - return pool.AppendCertsFromPEM(caPEM) -} - -func (m *Manager) loadExistingCertMeta() (certMeta, bool) { - if !m.manualCertAvailable() { - return certMeta{}, false - } - keyPair, err := tls.LoadX509KeyPair(m.cfg.TLSCertPath, m.cfg.TLSKeyPath) - if err != nil || len(keyPair.Certificate) == 0 { - return certMeta{}, false - } - leaf, err := x509.ParseCertificate(keyPair.Certificate[0]) - if err != nil { - return certMeta{}, false - } - now := time.Now() - if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) { - return certMeta{}, false - } - if !m.certMatchesExpectedIdentity(leaf) { - return certMeta{}, false - } - return certMeta{notBefore: leaf.NotBefore, notAfter: leaf.NotAfter}, true -} - -func (m *Manager) certMatchesExpectedIdentity(leaf *x509.Certificate) bool { - expected := make([]string, 0, 3) - serviceName := strings.TrimSpace(m.cfg.VaultServiceName) - serviceDomain := strings.TrimSpace(m.cfg.VaultServiceDomain) - bindHost := strings.TrimSpace(m.cfg.BindHost) - - if bindHost != "" { - expected = append(expected, strings.ToLower(bindHost)) - } - if serviceName != "" { - expected = append(expected, strings.ToLower(serviceName)) - } - if serviceName != "" && serviceDomain != "" { - expected = append(expected, strings.ToLower(serviceName+"."+serviceDomain)) - } - if len(expected) == 0 { - return true - } - - candidates := make([]string, 0, len(leaf.DNSNames)+1) - if cn := strings.ToLower(strings.TrimSpace(leaf.Subject.CommonName)); cn != "" { - candidates = append(candidates, cn) - } - for _, dns := range leaf.DNSNames { - if s := strings.ToLower(strings.TrimSpace(dns)); s != "" { - candidates = append(candidates, s) - } - } - - for _, want := range expected { - for _, got := range candidates { - if got == want { - return true - } - } - } - return false -} - -func (m *Manager) nextRenewAt() time.Time { - m.mu.RLock() - meta := m.current - m.mu.RUnlock() - - if meta.notAfter.IsZero() || meta.notBefore.IsZero() || !meta.notAfter.After(meta.notBefore) { - return time.Now().Add(m.cfg.VaultRetryInterval) - } - - lifetime := meta.notAfter.Sub(meta.notBefore) - rotationPoint := meta.notBefore.Add(lifetime * rotationFractionNumerator / rotationFractionDenominator) - if rotationPoint.Before(time.Now()) { - return time.Now().Add(minRotationWait) - } - return rotationPoint -} - -func certValidity(certPEM, keyPEM string) (time.Time, time.Time, error) { - keyPair, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("parse issued keypair: %w", err) - } - if len(keyPair.Certificate) == 0 { - return time.Time{}, time.Time{}, errors.New("issued keypair contains no certificate") - } - leaf, err := x509.ParseCertificate(keyPair.Certificate[0]) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("parse issued leaf certificate: %w", err) - } - return leaf.NotBefore, leaf.NotAfter, nil -} - -func combineCA(issuingCA string, chain []string) string { - parts := make([]string, 0, 1+len(chain)) - if trimmed := strings.TrimSpace(issuingCA); trimmed != "" { - parts = append(parts, trimmed) - } - for _, c := range chain { - if trimmed := strings.TrimSpace(c); trimmed != "" { - parts = append(parts, trimmed) - } - } - return strings.Join(parts, "\n") -} - -func parseCAChain(v interface{}) []string { - switch raw := v.(type) { - case []interface{}: - out := make([]string, 0, len(raw)) - for _, item := range raw { - if s := strings.TrimSpace(asString(item)); s != "" { - out = append(out, s) - } - } - return out - case []string: - out := make([]string, 0, len(raw)) - for _, item := range raw { - if s := strings.TrimSpace(item); s != "" { - out = append(out, s) - } - } - return out - default: - s := strings.TrimSpace(asString(v)) - if s == "" { - return nil - } - return []string{s} - } -} - -func asString(v interface{}) string { - switch t := v.(type) { - case string: - return t - case []byte: - return string(t) - case nil: - return "" - default: - return fmt.Sprintf("%v", t) - } -} - -func writeAtomic(path, contents string, mode os.FileMode) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - - tmp, err := os.CreateTemp(dir, ".tmp-cert-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer os.Remove(tmpName) - - if _, err := tmp.WriteString(contents); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(mode); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - - return os.Rename(tmpName, path) -} - -func writeCertBundleAtomic(certPath, certPEM, keyPath, keyPEM, caPath, caPEM string) error { - // Validate bundle first to avoid publishing broken material. - if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil { - return fmt.Errorf("invalid cert/key pair: %w", err) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM([]byte(caPEM)) { - return errors.New("invalid CA PEM") - } - - if err := writeAtomic(keyPath, keyPEM, 0o600); err != nil { - return err - } - if err := writeAtomic(certPath, certPEM, 0o644); err != nil { - return err - } - if err := writeAtomic(caPath, caPEM, 0o644); err != nil { - return err - } - return nil -} diff --git a/persys-gateway/internal/grpcbridge/bridge.go b/persys-gateway/internal/grpcbridge/bridge.go new file mode 100644 index 0000000..1c7543e --- /dev/null +++ b/persys-gateway/internal/grpcbridge/bridge.go @@ -0,0 +1,585 @@ +// Package grpcbridge turns any reflection-enabled gRPC service into a set +// of gin routes with zero generated-stub boilerplate: no per-RPC wrapper +// method, no per-RPC controller handler, no per-RPC route line. +// +// A Bridge can host any number of independent backends at once — each +// ServiceBinding carries its own Invoker and ReflectionSource, so a +// pool-aware, failover-capable backend (cluster control, many scheduler +// replicas) and a single-address backend (forgery, one fixed endpoint) +// register onto the same Bridge without either shape leaking into the +// other. +// +// What it deliberately does NOT do: replace connection selection, retry, +// or health tracking — that's each binding's Invoker's job. What it also +// does NOT do: bridge streaming RPCs. Any bidi/server-stream method is +// skipped during discovery and stays hand-written. +package grpcbridge + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +// Invoker performs one RPC and owns everything about *how* — which +// backend to dial, retry/failover, health tracking. A pool-aware backend +// (cluster control, ranking scheduler replicas per cluster/session/ +// workload) and a single-address backend (forgery) both implement this +// the same way they'd have dialed and called anyway; grpcbridge doesn't +// care which. +type Invoker interface { + InvokeDynamic(ctx context.Context, clusterID, sessionKey, workloadKey, fullMethod string, in, out proto.Message) error +} + +// ReflectionSource is anything that can open a connection for reflection +// queries. Kept separate from Invoker because descriptor discovery +// shouldn't participate in a pool's request-serving health/failover +// state — querying any one healthy replica is sufficient, since +// descriptors are identical across replicas of the same deployed version. +type ReflectionSource interface { + DialForReflection(ctx context.Context, clusterID string) (*grpc.ClientConn, error) +} + +// MethodAlias gives a friendlier, stable REST path to a method that has +// an established public API shape. Anything without an alias still gets +// the generic /rpc// path automatically — that's what +// makes a newly deployed RPC reachable, and discoverable via /rpc/_meta, +// with zero gateway changes. Promoting it to a nicer path later is +// purely a config addition, never a new handler. +type MethodAlias struct { + Method string // e.g. "ApplyWorkload" + Verb string // e.g. "POST" + Path string // e.g. "/workloads/schedule" (relative to GroupPrefix) + + // PathParams maps a gin path param name to the proto request field + // it should populate, e.g. {"id": "workload_id"} for + // "/workloads/:id". This is exactly what the old hand-written + // controllers did implicitly (`req.WorkloadId = ctx.Param("id")`) + // before being replaced by generic reflection-based dispatch — the + // generic path has no other way to know a URL segment corresponds + // to a specific field, so routes with path params MUST set this or + // the field is silently left empty. Only string-typed top-level + // fields are supported; nested fields need the client to send them + // nested in the JSON body instead (see e.g. TaintNodeRequest.Taint). + PathParams map[string]string +} + +// AuthResolver lets each bound service pick its own auth requirement. +type AuthResolver func() gin.HandlerFunc + +// KeyResolver extracts the cluster/session/workload affinity keys a +// pool-aware Invoker uses for candidate ranking (e.g. rendezvous hashing +// across scheduler replicas). Defaults to DefaultKeyResolver, which +// replicates the exact resolution order the gateway has always used +// (path param, then header, then cookie/query, then a hash of the +// Authorization header as a last resort) — this affects which backend +// replica a request lands on, so it's not just cosmetic and is worth +// keeping byte-for-byte consistent with prior behavior. +type KeyResolver interface { + ResolveClusterID(c *gin.Context) string + ResolveSessionKey(c *gin.Context) string + ResolveWorkloadKey(c *gin.Context) string +} + +type defaultKeyResolver struct{} + +// DefaultKeyResolver is used by any ServiceBinding that doesn't set its +// own Keys. +var DefaultKeyResolver KeyResolver = defaultKeyResolver{} + +func (defaultKeyResolver) ResolveClusterID(c *gin.Context) string { + if v := strings.TrimSpace(c.Param("cluster_id")); v != "" { + return v + } + if v := strings.TrimSpace(c.GetHeader("X-Persys-Cluster-ID")); v != "" { + return v + } + if v := strings.TrimSpace(c.Query("cluster_id")); v != "" { + return v + } + return "" +} + +func (defaultKeyResolver) ResolveSessionKey(c *gin.Context) string { + if v := strings.TrimSpace(c.GetHeader("X-Persys-Session")); v != "" { + return v + } + if cookie, err := c.Cookie("persys_session"); err == nil && strings.TrimSpace(cookie) != "" { + return cookie + } + authz := strings.TrimSpace(c.GetHeader("Authorization")) + if authz == "" { + return strings.TrimSpace(c.ClientIP()) + } + sum := sha256.Sum256([]byte(authz)) + return hex.EncodeToString(sum[:]) +} + +func (defaultKeyResolver) ResolveWorkloadKey(c *gin.Context) string { + if v := strings.TrimSpace(c.GetHeader("X-Persys-Workload-Key")); v != "" { + return v + } + if v := strings.TrimSpace(c.Param("id")); v != "" { + return v + } + if v := strings.TrimSpace(c.Query("workload_id")); v != "" { + return v + } + return c.Request.URL.Path +} + +// ServiceBinding fully describes one backend's presence on the gateway. +type ServiceBinding struct { + // FullyQualifiedName is the proto service name reflection reports, + // e.g. "persys.control.v1.AgentControl". + FullyQualifiedName string + + // GroupPrefix is where this service's routes mount, relative to + // wherever Register is called — e.g. "" to sit directly under + // /clusters/:cluster_id, or "/forgery" to sit under + // /clusters/:cluster_id/forgery. + GroupPrefix string + + // Invoker and Source are THIS binding's backend — independent per + // binding, so different backends can have entirely different + // connection shapes on the same Bridge. + Invoker Invoker + Source ReflectionSource + + // Keys resolves cluster/session/workload affinity for Invoker. Nil + // means DefaultKeyResolver. + Keys KeyResolver + + Auth AuthResolver + Aliases []MethodAlias + + // LocalFile is the compiled-in FileDescriptor for this service — the + // same one already embedded in the generated .pb.go this gateway + // links against (e.g. controlv1.File_control_proto). It's the + // backward-compatible fallback: if the backend doesn't implement + // gRPC reflection yet (an older scheduler/forgery deployment that + // predates reflection.Register being wired up there), the bridge + // builds its method table from this instead of failing to discover + // anything at all. Every method that exists in the gateway's own + // compiled proto is covered by this path with zero dependency on the + // backend; reflection only adds methods added to the backend's proto + // AFTER this gateway build — which the fallback can't know about + // until reflection becomes available or the gateway is rebuilt + // against a newer proto. + // + // Required if you want the binding to work at all against a backend + // without reflection. Leave nil only if you're certain every + // deployment target has reflection.Register wired up. + LocalFile protoreflect.FileDescriptor + + // RefreshInterval controls how often descriptors are re-fetched, so + // a backend redeploy with new RPCs shows up without a gateway + // restart. Defaults to 60s. Also governs how often a backend that + // currently lacks reflection is re-checked — if it's upgraded later, + // the bridge upgrades to live discovery automatically, no gateway + // restart needed either way. + RefreshInterval time.Duration + + // reflectionUnavailableLogged tracks whether we've already logged the + // "using fallback" notice for this binding, so refreshLoop doesn't + // repeat it every tick. Internal — not set by callers. + reflectionUnavailableLogged bool +} + +func (svc ServiceBinding) keys() KeyResolver { + if svc.Keys != nil { + return svc.Keys + } + return DefaultKeyResolver +} + +type Bridge struct { + mu sync.RWMutex + services map[string]*boundService +} + +type boundService struct { + binding ServiceBinding + methods []methodDesc +} + +type methodDesc struct { + name string + fullMethod string + input protoreflect.MessageDescriptor + output protoreflect.MessageDescriptor +} + +func New() *Bridge { + return &Bridge{services: map[string]*boundService{}} +} + +// Register implements router.Registrar. It performs one reflection +// query per bound service to discover methods, wires a generic handler +// per method (aliased or not), and starts a background refresh loop per +// service so newly deployed RPCs appear without restarting the gateway. +func (b *Bridge) Register(mountGroup *gin.RouterGroup, bindings ...ServiceBinding) error { + for _, svc := range bindings { + svc := svc + b.mu.Lock() + b.services[svc.FullyQualifiedName] = &boundService{binding: svc} + b.mu.Unlock() + + if err := b.refresh(context.Background(), svc.FullyQualifiedName); err != nil { + return fmt.Errorf("grpcbridge: discover %s: %w", svc.FullyQualifiedName, err) + } + b.mountRoutes(mountGroup, svc) + + interval := svc.RefreshInterval + if interval <= 0 { + interval = 60 * time.Second + } + go b.refreshLoop(svc.FullyQualifiedName, interval) + } + return nil +} + +func (b *Bridge) refreshLoop(serviceName string, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + // Errors here are deliberately swallowed past logging: a + // transient reflection failure shouldn't take down routes that + // are already registered and working. New methods just won't + // appear until the next successful refresh. + _ = b.refresh(context.Background(), serviceName) + } +} + +func (b *Bridge) refresh(ctx context.Context, serviceName string) error { + b.mu.RLock() + bound, ok := b.services[serviceName] + b.mu.RUnlock() + if !ok { + return fmt.Errorf("grpcbridge: %s is not registered", serviceName) + } + + svcDesc, viaReflection, err := b.discoverViaReflection(ctx, bound.binding) + if err != nil { + if bound.binding.LocalFile == nil { + return fmt.Errorf("reflection failed for %s and no LocalFile fallback configured: %w", serviceName, err) + } + svcDesc = bound.binding.LocalFile.Services().ByName(protoreflect.Name(lastSegment(serviceName))) + if svcDesc == nil { + return fmt.Errorf("service %q not found in LocalFile fallback descriptor", serviceName) + } + if !bound.binding.reflectionUnavailableLogged { + log.Printf("grpcbridge: %s does not support gRPC reflection (%v) — falling back to the "+ + "compiled-in proto descriptor. Every method that exists in this gateway's build is still "+ + "served; methods added to the backend's proto after this gateway was built won't appear "+ + "until either the backend adds reflection.Register or the gateway is rebuilt against the "+ + "newer proto. Retrying reflection every %s in case the backend is upgraded.", + serviceName, err, refreshIntervalOrDefault(bound.binding.RefreshInterval)) + bound.binding.reflectionUnavailableLogged = true + } + } else if viaReflection && bound.binding.reflectionUnavailableLogged { + log.Printf("grpcbridge: %s now supports gRPC reflection — switched from the compiled-in fallback "+ + "descriptor to live discovery.", serviceName) + bound.binding.reflectionUnavailableLogged = false + } + + discovered := methodsFromServiceDescriptor(serviceName, svcDesc) + + b.mu.Lock() + b.services[serviceName].methods = discovered + b.mu.Unlock() + return nil +} + +// discoverViaReflection queries the backend's gRPC reflection service. The +// second return value is false (with a nil error) only in impossible +// call patterns — callers should treat any non-nil error as "reflection +// unavailable, use the fallback" without needing to distinguish further +// (a genuinely broken connection and a backend with reflection.Register +// simply not called both surface the same way from the client's side). +func (b *Bridge) discoverViaReflection(ctx context.Context, binding ServiceBinding) (protoreflect.ServiceDescriptor, bool, error) { + conn, err := binding.Source.DialForReflection(ctx, "") + if err != nil { + return nil, false, err + } + defer conn.Close() + + client := reflectionpb.NewServerReflectionClient(conn) + stream, err := client.ServerReflectionInfo(ctx) + if err != nil { + return nil, false, err + } + defer stream.CloseSend() + + if err := stream.Send(&reflectionpb.ServerReflectionRequest{ + MessageRequest: &reflectionpb.ServerReflectionRequest_FileContainingSymbol{ + FileContainingSymbol: binding.FullyQualifiedName, + }, + }); err != nil { + return nil, false, err + } + resp, err := stream.Recv() + if err != nil { + // Unimplemented is the expected status when reflection.Register + // was never called on the backend — this is the normal, + // backward-compatible case for an older deployment, not a bug. + if status.Code(err) == codes.Unimplemented { + return nil, false, fmt.Errorf("backend does not implement gRPC reflection: %w", err) + } + return nil, false, err + } + fdResp := resp.GetFileDescriptorResponse() + if fdResp == nil { + return nil, false, fmt.Errorf("no file descriptor for service %q", binding.FullyQualifiedName) + } + + var fdProtos []*descriptorpb.FileDescriptorProto + for _, raw := range fdResp.FileDescriptorProto { + fdp := &descriptorpb.FileDescriptorProto{} + if err := proto.Unmarshal(raw, fdp); err != nil { + return nil, false, err + } + fdProtos = append(fdProtos, fdp) + } + files, err := protodesc.NewFiles(&descriptorpb.FileDescriptorSet{File: fdProtos}) + if err != nil { + return nil, false, err + } + + var svcDesc protoreflect.ServiceDescriptor + files.RangeFiles(func(fd protoreflect.FileDescriptor) bool { + if sd := fd.Services().ByName(protoreflect.Name(lastSegment(binding.FullyQualifiedName))); sd != nil { + svcDesc = sd + return false + } + return true + }) + if svcDesc == nil { + return nil, false, fmt.Errorf("service %q not found in reflection response", binding.FullyQualifiedName) + } + return svcDesc, true, nil +} + +func methodsFromServiceDescriptor(serviceName string, svcDesc protoreflect.ServiceDescriptor) []methodDesc { + var discovered []methodDesc + methods := svcDesc.Methods() + for i := 0; i < methods.Len(); i++ { + m := methods.Get(i) + if m.IsStreamingClient() || m.IsStreamingServer() { + continue // streaming stays hand-written, see package doc + } + discovered = append(discovered, methodDesc{ + name: string(m.Name()), + fullMethod: fmt.Sprintf("/%s/%s", serviceName, m.Name()), + input: m.Input(), + output: m.Output(), + }) + } + return discovered +} + +func refreshIntervalOrDefault(d time.Duration) time.Duration { + if d <= 0 { + return 60 * time.Second + } + return d +} + +func (b *Bridge) mountRoutes(mountGroup *gin.RouterGroup, svc ServiceBinding) { + group := mountGroup.Group(svc.GroupPrefix) + if svc.Auth != nil { + group.Use(svc.Auth()) + } + + aliasByMethod := map[string]MethodAlias{} + for _, a := range svc.Aliases { + aliasByMethod[a.Method] = a + } + for _, a := range svc.Aliases { + a := a + group.Handle(a.Verb, a.Path, b.handlerFor(svc, a.Method, a.PathParams)) + } + + // Discovery endpoint: lets persysctl and the Go SDK ask "what can I + // call here" instead of hardcoding paths. + group.GET("/rpc/_meta", func(c *gin.Context) { + b.mu.RLock() + methods := b.services[svc.FullyQualifiedName].methods + b.mu.RUnlock() + + type methodInfo struct { + Method string `json:"method"` + Path string `json:"path"` + Verb string `json:"verb"` + Input string `json:"input_type"` + Output string `json:"output_type"` + } + out := make([]methodInfo, 0, len(methods)) + for _, m := range methods { + info := methodInfo{ + Method: m.name, + Verb: "POST", + Path: svc.GroupPrefix + "/rpc/" + lastSegment(svc.FullyQualifiedName) + "/" + m.name, + Input: string(m.input.FullName()), + Output: string(m.output.FullName()), + } + if a, ok := aliasByMethod[m.name]; ok { + info.Verb = a.Verb + info.Path = svc.GroupPrefix + a.Path + } + out = append(out, info) + } + c.JSON(http.StatusOK, gin.H{"service": svc.FullyQualifiedName, "methods": out}) + }) + + // Generic fallback: /rpc//. + group.POST("/rpc/"+lastSegment(svc.FullyQualifiedName)+"/:method", func(c *gin.Context) { + method := c.Param("method") + if _, aliased := aliasByMethod[method]; aliased { + c.JSON(http.StatusGone, gin.H{"error": "use the dedicated path for this method", "method": method}) + return + } + b.handlerFor(svc, method, nil)(c) + }) +} + +func (b *Bridge) handlerFor(svc ServiceBinding, method string, pathParams map[string]string) gin.HandlerFunc { + return func(c *gin.Context) { + b.mu.RLock() + bound := b.services[svc.FullyQualifiedName] + b.mu.RUnlock() + + var md *methodDesc + for i := range bound.methods { + if bound.methods[i].name == method { + md = &bound.methods[i] + break + } + } + if md == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown method", "method": method}) + return + } + + in := dynamicpb.NewMessage(md.input) + if c.Request.ContentLength != 0 { + body := map[string]any{} + if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } + raw, _ := json.Marshal(body) + if err := protojson.Unmarshal(raw, in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "request does not match " + string(md.input.FullName()), "detail": err.Error()}) + return + } + } + + // Applied AFTER the body decode, so the URL is always + // authoritative for these fields even if a caller's JSON body + // redundantly (or incorrectly) also sets them — e.g. + // DELETE /workloads/:id should delete the workload named by the + // URL, full stop, regardless of body contents. + for ginParam, fieldName := range pathParams { + value := c.Param(ginParam) + if value == "" { + continue + } + fd := md.input.Fields().ByName(protoreflect.Name(fieldName)) + if fd == nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("gateway misconfiguration: %s has no field %q for path param %q", md.input.FullName(), fieldName, ginParam), + }) + return + } + if fd.Kind() != protoreflect.StringKind { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("gateway misconfiguration: field %q is not a string field, path-param injection only supports strings", fieldName), + }) + return + } + in.Set(fd, protoreflect.ValueOfString(value)) + } + + out := dynamicpb.NewMessage(md.output) + keys := svc.keys() + clusterID := keys.ResolveClusterID(c) + sessionKey := keys.ResolveSessionKey(c) + workloadKey := keys.ResolveWorkloadKey(c) + + err := bound.binding.Invoker.InvokeDynamic(c.Request.Context(), clusterID, sessionKey, workloadKey, md.fullMethod, in, out) + if err != nil { + writeInvokeError(c, err) + return + } + + data, err := protojson.Marshal(out) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal response"}) + return + } + c.Data(http.StatusOK, "application/json", data) + } +} + +// writeInvokeError maps known failure modes to sensible status codes, +// same distinctions the old ProwController.writeProxyError made. +func writeInvokeError(c *gin.Context, err error) { + switch { + case isUnknownCluster(err): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case isSchedulerUnavailable(err): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "no healthy scheduler available"}) + default: + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } +} + +// ErrChecker lets an Invoker's package-specific sentinel errors +// (services.ErrUnknownCluster, services.ErrNoHealthySchedulers) map to +// the right HTTP status without grpcbridge importing services and +// creating a cycle. Set via RegisterErrorClassifiers at startup. +var ( + isUnknownCluster = func(error) bool { return false } + isSchedulerUnavailable = func(error) bool { return false } +) + +// RegisterErrorClassifiers lets main.go wire services.IsUnknownCluster / +// services.IsSchedulerUnavailable in without grpcbridge depending on the +// services package. +func RegisterErrorClassifiers(unknownCluster, schedulerUnavailable func(error) bool) { + if unknownCluster != nil { + isUnknownCluster = unknownCluster + } + if schedulerUnavailable != nil { + isSchedulerUnavailable = schedulerUnavailable + } +} + +func lastSegment(fqName string) string { + for i := len(fqName) - 1; i >= 0; i-- { + if fqName[i] == '.' { + return fqName[i+1:] + } + } + return fqName +} diff --git a/persys-gateway/internal/router/bindings.go b/persys-gateway/internal/router/bindings.go new file mode 100644 index 0000000..0bacedd --- /dev/null +++ b/persys-gateway/internal/router/bindings.go @@ -0,0 +1,134 @@ +// bindings.go is the single place the gateway's entire dynamic RPC +// surface is declared. Adding a method for persysctl or the SDK to call +// is very likely a one-line addition here — a MethodAlias, or nothing at +// all if the generic /rpc// path is fine. +// +// Any alias whose Path has a :param MUST set PathParams mapping it to +// the proto field it feeds — see grpcbridge.MethodAlias.PathParams. This +// was the source of a real bug: routes like GET /workloads/:id worked at +// the routing layer but silently sent an empty workload_id to the +// backend, because nothing was translating the URL segment into the +// request message. Every alias below with a :id/:name segment has been +// checked against the actual generated proto field name (not guessed) — +// see the field comments. +package router + +import ( + "github.com/gin-gonic/gin" + + "github.com/persys-dev/persys-cloud/persys-gateway/internal/catalog" + controlv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/controlv1" + forgeryv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/forgeryv1" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/grpcbridge" +) + +// controlv1 and forgeryv1 are imported here (not just in services/) purely +// to supply LocalFile below — the compiled-in fallback descriptor used +// when a backend doesn't yet support gRPC reflection (see +// grpcbridge.ServiceBinding.LocalFile doc). go.mod already has +// replace github.com/persys-dev/persys-cloud/pkg => ../pkg +// for shared code like certmanager. If/when these generated packages +// move to that shared pkg module too (so scheduler, compute-agent, and +// this gateway all build from one canonical copy instead of each +// vendoring their own), this is the only import to change — everything +// else in this file references controlv1./forgeryv1. symbols generically. + +// ClusterControlBindingInvoker is satisfied by services.ClusterControlService. +type ClusterControlBindingInvoker interface { + grpcbridge.Invoker + grpcbridge.ReflectionSource +} + +// ClusterControlBinding is the workload/node scheduling API — talks to +// AgentControl on the per-cluster scheduler pool, HA-aware failover +// preserved via ClusterControlService.InvokeDynamic (the exact same +// candidate/dial/retry loop invokeControlRPC has always used). +// +// Aliases below are the STABLE, documented paths the SDK and persysctl +// should target. Anything not listed is still fully callable at +// /clusters/:cluster_id/rpc/AgentControl/ — check +// GET /clusters/:cluster_id/rpc/_meta for the live list, including +// brand-new methods that haven't been aliased yet. +func ClusterControlBinding(clusterControl ClusterControlBindingInvoker, r *Router) grpcbridge.ServiceBinding { + return grpcbridge.ServiceBinding{ + FullyQualifiedName: "persys.control.v1.AgentControl", + GroupPrefix: "", + Invoker: clusterControl, + Source: clusterControl, + LocalFile: controlv1.File_control_proto, + Auth: func() gin.HandlerFunc { return r.Resolve(catalog.AuthUser) }, + Aliases: []grpcbridge.MethodAlias{ + {Method: "ApplyWorkload", Verb: "POST", Path: "/workloads/schedule"}, + {Method: "ListWorkloads", Verb: "GET", Path: "/workloads"}, + // GetWorkloadRequest.WorkloadId — json name "workload_id" + {Method: "GetWorkload", Verb: "GET", Path: "/workloads/:id", PathParams: map[string]string{"id": "workload_id"}}, + // DeleteWorkloadRequest.WorkloadId + {Method: "DeleteWorkload", Verb: "DELETE", Path: "/workloads/:id", PathParams: map[string]string{"id": "workload_id"}}, + // RetryWorkloadRequest.WorkloadId + {Method: "RetryWorkload", Verb: "POST", Path: "/workloads/:id/retry", PathParams: map[string]string{"id": "workload_id"}}, + {Method: "ListNodes", Verb: "GET", Path: "/nodes"}, + // GetNodeRequest.NodeId — json name "node_id" + {Method: "GetNode", Verb: "GET", Path: "/nodes/:id", PathParams: map[string]string{"id": "node_id"}}, + // DrainNodeRequest.NodeId (Reason comes from the JSON body) + {Method: "DrainNode", Verb: "POST", Path: "/nodes/:id/drain", PathParams: map[string]string{"id": "node_id"}}, + // UndrainNodeRequest.NodeId (Reason comes from the JSON body) + {Method: "UndrainNode", Verb: "POST", Path: "/nodes/:id/undrain", PathParams: map[string]string{"id": "node_id"}}, + // TaintNodeRequest.NodeId. NOTE: TaintNodeRequest.Taint is a + // NESTED message (*NodeTaint{Key,Value,Effect}), unlike every + // other alias here — the client must send a nested + // {"taint":{"key":...,"value":...,"effect":...}} body, not a + // flat one. PathParams only reaches top-level string fields. + {Method: "TaintNode", Verb: "POST", Path: "/nodes/:id/taint", PathParams: map[string]string{"id": "node_id"}}, + // UntaintNodeRequest.NodeId (Key/Effect are flat top-level + // fields, unlike TaintNode — a flat JSON body is correct here) + {Method: "UntaintNode", Verb: "POST", Path: "/nodes/:id/untaint", PathParams: map[string]string{"id": "node_id"}}, + // SetNodeLabelRequest.NodeId (Key/Value are flat top-level fields) + {Method: "SetNodeLabel", Verb: "POST", Path: "/nodes/:id/labels", PathParams: map[string]string{"id": "node_id"}}, + // DeleteNodeLabelRequest.NodeId (Key is a flat top-level field) + {Method: "DeleteNodeLabel", Verb: "DELETE", Path: "/nodes/:id/labels", PathParams: map[string]string{"id": "node_id"}}, + {Method: "GetClusterSummary", Verb: "GET", Path: "/cluster/metrics"}, + // RegisterNode, Heartbeat: intentionally NOT aliased — those + // are compute-agent-to-scheduler internal calls, not + // SDK/persysctl surface. Still technically reachable via + // /rpc/AgentControl/RegisterNode if something needs it, but + // nothing should be pointed at that path deliberately. + }, + } +} + +// ForgeryBindingInvoker is satisfied by services.ForgeryService. +type ForgeryBindingInvoker interface { + grpcbridge.Invoker + grpcbridge.ReflectionSource +} + +// ForgeryBinding is the CI/CD API — talks to ForgeryControl on +// persys-forgery's single fixed address, no pool. +func ForgeryBinding(forgery ForgeryBindingInvoker, r *Router) grpcbridge.ServiceBinding { + return grpcbridge.ServiceBinding{ + FullyQualifiedName: "persys.forgery.v1.ForgeryControl", + GroupPrefix: "/forgery", + Invoker: forgery, + Source: forgery, + LocalFile: forgeryv1.File_forgery_proto, + Auth: func() gin.HandlerFunc { return r.Resolve(catalog.AuthUser) }, + Aliases: []grpcbridge.MethodAlias{ + {Method: "UpsertProject", Verb: "POST", Path: "/projects/upsert"}, + // GetProjectRequest field is Name, NOT a "project_id" — do + // not assume REST-style ID naming without checking the proto. + {Method: "GetProject", Verb: "GET", Path: "/projects/:id", PathParams: map[string]string{"id": "name"}}, + {Method: "ListProjects", Verb: "GET", Path: "/projects"}, + // DeleteProjectRequest field is also Name. + {Method: "DeleteProject", Verb: "DELETE", Path: "/projects/:id", PathParams: map[string]string{"id": "name"}}, + {Method: "TriggerBuild", Verb: "POST", Path: "/builds/trigger"}, + {Method: "ListPipelineStatus", Verb: "GET", Path: "/pipeline/status"}, + {Method: "ForwardWebhook", Verb: "POST", Path: "/webhooks/test"}, + {Method: "RegisterWebhook", Verb: "POST", Path: "/webhooks/register"}, + {Method: "ListUserRepositories", Verb: "GET", Path: "/repositories"}, + // StoreGitHubCredential: NOT aliased — takes a raw token in + // the request body. Worth a deliberate look at whether it + // belongs behind RequireUser only (never RequireMTLSOrUser) + // before giving it a stable path at all. + }, + } +} diff --git a/persys-gateway/internal/router/router.go b/persys-gateway/internal/router/router.go new file mode 100644 index 0000000..085a051 --- /dev/null +++ b/persys-gateway/internal/router/router.go @@ -0,0 +1,118 @@ +// Package router wires the gateway's HTTP surface from two sources: +// +// 1. The service catalog (internal/catalog) — plain reverse-proxy +// services, registered generically. +// 2. Self-registering controllers implementing Registrar — services +// that need bespoke logic (ClusterMetaController, webhook HMAC +// verification, GitHub OAuth) or typed proto translation. +// +// The dynamic RPC surface (internal/grpcbridge) is wired separately in +// main.go since it needs its own mount group and binding list — see +// internal/router/bindings.go. +package router + +import ( + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/persys-dev/persys-cloud/persys-gateway/internal/authn" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/catalog" + "github.com/persys-dev/persys-cloud/persys-gateway/utils" +) + +// Registrar is implemented by any controller that owns its own routes. +type Registrar interface { + Register(rg *gin.RouterGroup, auth *authn.Middleware) +} + +type Router struct { + auth *authn.Middleware + mode catalog.DeploymentMode +} + +func New(auth *authn.Middleware, mode catalog.DeploymentMode) *Router { + return &Router{auth: auth, mode: mode} +} + +// RegisterCatalog builds generic reverse-proxy routes from an optional +// service catalog file. If the file doesn't exist, this is a no-op (info +// log, not fatal) — the catalog is for services added later without any +// gateway code change; not having one yet is a normal, out-of-the-box +// state, not an error. +func (r *Router) RegisterCatalog(rg *gin.RouterGroup, catalogPath string) error { + if catalogPath == "" { + return nil + } + if _, err := os.Stat(catalogPath); os.IsNotExist(err) { + return nil + } + cat, err := catalog.Load(catalogPath) + if err != nil { + return err + } + for _, svc := range cat.Services { + if !svc.Enabled { + continue + } + svc := svc + + group := rg.Group(svc.PathPrefix) + switch svc.Auth.Resolve(r.mode) { + case catalog.AuthUser: + group.Use(r.auth.RequireUser()) + case catalog.AuthMTLS: + group.Use(r.auth.RequireMTLS()) + case catalog.AuthEither: + group.Use(r.auth.RequireMTLSOrUser()) + case catalog.AuthNone: + // intentionally no middleware + } + + group.Any("/*proxyPath", catalogProxyHandler(svc)) + } + return nil +} + +// RegisterControllers wires any number of self-registering controllers +// onto the given group. +func (r *Router) RegisterControllers(rg *gin.RouterGroup, registrars ...Registrar) { + for _, reg := range registrars { + reg.Register(rg, r.auth) + } +} + +// Resolve exposes the deployment-mode auth resolution to callers that +// need to pick a raw gin.HandlerFunc without a full catalog entry (e.g. +// grpcbridge bindings in main.go). +func (r *Router) Resolve(intent catalog.AuthMode) gin.HandlerFunc { + switch intent.Resolve(r.mode) { + case catalog.AuthUser: + return r.auth.RequireUser() + case catalog.AuthMTLS: + return r.auth.RequireMTLS() + case catalog.AuthEither: + return r.auth.RequireMTLSOrUser() + default: + return func(c *gin.Context) { c.Next() } + } +} + +func catalogProxyHandler(svc catalog.Service) gin.HandlerFunc { + timeout := svc.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + return func(c *gin.Context) { + upstreamPath := c.Param("proxyPath") + if !svc.StripPrefix { + upstreamPath = strings.TrimSuffix(svc.PathPrefix, "/") + upstreamPath + } + utils.ProxyRequest(c, svc.UpstreamAddr+upstreamPath, &utils.ProxyOptions{ + Timeout: timeout, + Headers: map[string]string{"X-Forwarded-For": c.ClientIP()}, + }) + } +} diff --git a/persys-gateway/internal/store/schema.sql b/persys-gateway/internal/store/schema.sql new file mode 100644 index 0000000..b936f28 --- /dev/null +++ b/persys-gateway/internal/store/schema.sql @@ -0,0 +1,60 @@ +-- Replaces MongoDB's users/sessions/webhooks collections. Only three +-- tables: this is everything the gateway's code actually reads or +-- writes today. +-- +-- Deliberately absent: +-- - "repos": the old Mongo "repos" collection was created at startup +-- but never read from or written to anywhere in the codebase — +-- persys-forgery owns repository data via its own gRPC API +-- (ListUserRepositories, RegisterWebhook). Carrying an unused table +-- forward would just be new cruft in a new database. +-- - "cluster_state": was persisted to Mongo every 15s, but +-- ClusterMetaController already serves cluster state from the +-- in-memory scheduler pool directly (SnapshotClusters) — nothing +-- ever read the persisted copy back. Dropped rather than migrated. +-- - "cluster_owners" (multi-tenancy): designed in an earlier pass but +-- not wired into any code path yet — add it in its own migration +-- once managed-mode ownership checks are actually implemented, +-- rather than shipping an unused table now. + +CREATE TABLE IF NOT EXISTS users ( + user_id BIGINT PRIMARY KEY, -- GitHub's numeric user ID + login TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + company TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + github_token TEXT NOT NULL DEFAULT '', + persys_token TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_users_state ON users(state) WHERE state <> ''; + +-- OAuth CSRF state. Was a package-level Go variable in the original +-- code, overwritten on every login attempt — a real race under +-- concurrent logins. A row per in-flight attempt, keyed by the state +-- token itself, closes that outright. +CREATE TABLE IF NOT EXISTS oauth_sessions ( + state TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + consumed BOOLEAN NOT NULL DEFAULT false +); + +CREATE TABLE IF NOT EXISTS webhook_events ( + delivery_id TEXT PRIMARY KEY, + event_name TEXT NOT NULL, + repository TEXT NOT NULL, + cluster_id TEXT NOT NULL DEFAULT '', + verified BOOLEAN NOT NULL DEFAULT false, + status TEXT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + received_at TIMESTAMPTZ, + next_retry_at TIMESTAMPTZ, + last_updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/persys-gateway/internal/store/store.go b/persys-gateway/internal/store/store.go new file mode 100644 index 0000000..18622e9 --- /dev/null +++ b/persys-gateway/internal/store/store.go @@ -0,0 +1,196 @@ +// Package store is the gateway's only database dependency now — Postgres +// via pgx, replacing MongoDB entirely. Three tables (see schema.sql): +// users, oauth_sessions, webhook_events. That's the complete list of +// things the gateway's code actually persists; see schema.sql for what +// was deliberately NOT carried over from Mongo and why. +package store + +import ( + "context" + _ "embed" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/persys-dev/persys-cloud/persys-gateway/models" +) + +//go:embed schema.sql +var schemaSQL string + +var ErrNotFound = errors.New("not found") + +type Store struct { + pool *pgxpool.Pool +} + +// New connects to Postgres and applies the schema. The schema is plain +// CREATE TABLE/INDEX IF NOT EXISTS — safe to run on every startup rather +// than needing a separate migration-runner step, which matters for the +// self-hosted "works out of the box" story: a fresh Postgres just works, +// no manual migration command required first. +func New(ctx context.Context, dsn string, maxConns int32) (*Store, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse postgres dsn: %w", err) + } + if maxConns > 0 { + cfg.MaxConns = maxConns + } + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("connect to postgres: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + return &Store{pool: pool}, nil +} + +func (s *Store) Migrate(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, schemaSQL); err != nil { + return fmt.Errorf("run schema migration: %w", err) + } + return nil +} + +func (s *Store) Close() { s.pool.Close() } + +// ---- Users ---- + +// UpsertUser creates or updates a user record via Postgres' native +// ON CONFLICT — atomic by construction. Replaces the old Mongo +// find-then-branch logic, which had a bug where InsertOne ran +// unconditionally regardless of which branch was taken, relying on a +// unique index that was never actually created to reject the resulting +// duplicate. Concurrent logins for the same user_id cannot create two +// rows here no matter how they interleave; Postgres' own conflict +// resolution guarantees it, not application-level branching. +func (s *Store) UpsertUser(ctx context.Context, u *models.UserInput) (*models.DBResponse, error) { + const q = ` + INSERT INTO users (user_id, login, name, email, company, url, github_token, persys_token, state, status, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now()) + ON CONFLICT (user_id) DO UPDATE SET + login = EXCLUDED.login, + name = CASE WHEN EXCLUDED.name <> '' THEN EXCLUDED.name ELSE users.name END, + email = CASE WHEN EXCLUDED.email <> '' THEN EXCLUDED.email ELSE users.email END, + company = CASE WHEN EXCLUDED.company <> '' THEN EXCLUDED.company ELSE users.company END, + url = CASE WHEN EXCLUDED.url <> '' THEN EXCLUDED.url ELSE users.url END, + github_token = EXCLUDED.github_token, + persys_token = EXCLUDED.persys_token, + state = EXCLUDED.state, + updated_at = now() + RETURNING user_id, login, name, email, company, url, github_token, persys_token, state, status, + created_at::text, updated_at::text + ` + row := s.pool.QueryRow(ctx, q, u.UserID, u.Login, u.Name, u.Email, u.Company, u.URL, u.GithubToken, u.PersysToken, u.State, u.Status) + return scanUser(row) +} + +func (s *Store) FindUserByID(ctx context.Context, userID int64) (*models.DBResponse, error) { + const q = ` + SELECT user_id, login, name, email, company, url, github_token, persys_token, state, status, + created_at::text, updated_at::text + FROM users WHERE user_id = $1 + ` + return scanUser(s.pool.QueryRow(ctx, q, userID)) +} + +// FindUserByState supports the CLI login flow, which polls by the OAuth +// state token issued at the start of the flow rather than a user ID it +// doesn't have yet. +func (s *Store) FindUserByState(ctx context.Context, state string) (*models.DBResponse, error) { + const q = ` + SELECT user_id, login, name, email, company, url, github_token, persys_token, state, status, + created_at::text, updated_at::text + FROM users WHERE state = $1 + ORDER BY updated_at DESC LIMIT 1 + ` + return scanUser(s.pool.QueryRow(ctx, q, state)) +} + +func scanUser(row pgx.Row) (*models.DBResponse, error) { + var u models.DBResponse + err := row.Scan( + &u.UserID, &u.Login, &u.Name, &u.Email, &u.Company, &u.URL, + &u.GithubToken, &u.PersysToken, &u.State, &u.Status, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &u, nil +} + +// ---- OAuth CSRF state ---- +// +// Replaces the package-level `state` Go variable the original +// AuthController held, overwritten on every login attempt — a real race +// under concurrent logins. A row per in-flight attempt, keyed by the +// state token itself, closes that outright: two logins never share a +// mutable slot. + +func (s *Store) StoreOAuthState(ctx context.Context, state string, ttl time.Duration) error { + const q = ` + INSERT INTO oauth_sessions (state, created_at, expires_at, consumed) + VALUES ($1, now(), now() + $2, false) + ON CONFLICT (state) DO UPDATE SET created_at = now(), expires_at = now() + $2, consumed = false + ` + _, err := s.pool.Exec(ctx, q, state, ttl) + return err +} + +// ValidateAndConsumeState atomically checks and marks a CSRF state token +// used in one round trip, so a token can be redeemed exactly once even +// under concurrent requests racing the same state value. +func (s *Store) ValidateAndConsumeState(ctx context.Context, state string) error { + const q = ` + UPDATE oauth_sessions SET consumed = true + WHERE state = $1 AND consumed = false AND expires_at > now() + ` + tag, err := s.pool.Exec(ctx, q, state) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("oauth state invalid, expired, or already used") + } + return nil +} + +// ---- Webhook delivery audit trail ---- + +func (s *Store) UpsertWebhookEvent(ctx context.Context, e *models.WebhookEvent) error { + const q = ` + INSERT INTO webhook_events + (delivery_id, event_name, repository, cluster_id, verified, status, attempts, last_error, received_at, next_retry_at, last_updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (delivery_id) DO UPDATE SET + event_name = EXCLUDED.event_name, + repository = EXCLUDED.repository, + cluster_id = EXCLUDED.cluster_id, + verified = EXCLUDED.verified, + status = EXCLUDED.status, + attempts = EXCLUDED.attempts, + last_error = EXCLUDED.last_error, + next_retry_at = EXCLUDED.next_retry_at, + last_updated_at = EXCLUDED.last_updated_at + ` + _, err := s.pool.Exec(ctx, q, + e.DeliveryID, e.EventName, e.Repository, e.ClusterID, e.Verified, e.Status, e.Attempts, e.LastError, + nilIfZero(e.ReceivedAt), nilIfZero(e.NextRetryAt), e.LastUpdatedAt, + ) + return err +} + +func nilIfZero(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} diff --git a/persys-gateway/models/cluster_state.model.go b/persys-gateway/models/cluster_state.model.go deleted file mode 100644 index d093063..0000000 --- a/persys-gateway/models/cluster_state.model.go +++ /dev/null @@ -1,19 +0,0 @@ -package models - -import "time" - -type SchedulerState struct { - ID string `bson:"id" json:"id"` - Address string `bson:"address" json:"address"` - IsLeader bool `bson:"is_leader" json:"is_leader"` - Healthy bool `bson:"healthy" json:"healthy"` - LastSeen time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"` -} - -type ClusterState struct { - ClusterID string `bson:"cluster_id" json:"cluster_id"` - Name string `bson:"name" json:"name"` - RoutingStrategy string `bson:"routing_strategy" json:"routing_strategy"` - Schedulers []SchedulerState `bson:"schedulers" json:"schedulers"` - UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` -} diff --git a/persys-gateway/models/event.model.go b/persys-gateway/models/event.model.go index faa6b38..df72705 100644 --- a/persys-gateway/models/event.model.go +++ b/persys-gateway/models/event.model.go @@ -3,15 +3,15 @@ package models import "time" type WebhookEvent struct { - DeliveryID string `bson:"delivery_id" json:"delivery_id"` - EventName string `bson:"event_name" json:"event_name"` - Repository string `bson:"repository" json:"repository"` - ClusterID string `bson:"cluster_id" json:"cluster_id"` - Verified bool `bson:"verified" json:"verified"` - Status string `bson:"status" json:"status"` - Attempts int `bson:"attempts" json:"attempts"` - LastError string `bson:"last_error,omitempty" json:"last_error,omitempty"` - ReceivedAt time.Time `bson:"received_at" json:"received_at"` - NextRetryAt time.Time `bson:"next_retry_at,omitempty" json:"next_retry_at,omitempty"` - LastUpdatedAt time.Time `bson:"last_updated_at" json:"last_updated_at"` + DeliveryID string `json:"delivery_id"` + EventName string `json:"event_name"` + Repository string `json:"repository"` + ClusterID string `json:"cluster_id"` + Verified bool `json:"verified"` + Status string `json:"status"` + Attempts int `json:"attempts"` + LastError string `json:"last_error,omitempty"` + ReceivedAt time.Time `json:"received_at"` + NextRetryAt time.Time `json:"next_retry_at,omitempty"` + LastUpdatedAt time.Time `json:"last_updated_at"` } diff --git a/persys-gateway/models/repos.model.go b/persys-gateway/models/repos.model.go deleted file mode 100755 index 1b22ec6..0000000 --- a/persys-gateway/models/repos.model.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -type Repos struct { - RepoID int64 `json:"repoID" bson:"repoID"` - GitURL string `json:"gitURL" bson:"gitURL"` - Name string `json:"name" bson:"name"` - Owner string `json:"owner" bson:"owner"` - UserID int64 `json:"userID" bson:"userID"` - Private bool `json:"private" bson:"private"` - AccessToken string `json:"accessToken" bson:"accessToken"` - WebhookURL string `json:"webhookURL" bson:"webhookURL"` - EventID int64 `json:"eventID" bson:"eventID"` - CreatedAt string `json:"createdAt" bson:"createdAt"` -} diff --git a/persys-gateway/models/session.model.go b/persys-gateway/models/session.model.go index 068e176..59d4b11 100644 --- a/persys-gateway/models/session.model.go +++ b/persys-gateway/models/session.model.go @@ -2,9 +2,12 @@ package models import "time" +// OAuthSession mirrors the oauth_sessions table (internal/store/schema.sql). +// Not required by store.Store's own methods, which scan directly into +// primitives, but kept as the documented shape of what that table holds. type OAuthSession struct { - State string `bson:"state" json:"state"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - ExpiresAt time.Time `bson:"expires_at" json:"expires_at"` - Consumed bool `bson:"consumed" json:"consumed"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Consumed bool `json:"consumed"` } diff --git a/persys-gateway/models/user.model.go b/persys-gateway/models/user.model.go index f2d458b..6becdae 100755 --- a/persys-gateway/models/user.model.go +++ b/persys-gateway/models/user.model.go @@ -1,50 +1,41 @@ package models -import ( - "time" - - "go.mongodb.org/mongo-driver/bson/primitive" -) - type CliReq struct { - State string `json:"state" bson:"state"` + State string `json:"state"` } type UserInput struct { - Login string `json:"login" bson:"login"` - Name string `json:"name" bson:"name"` - Email string `json:"email" bson:"email"` - Company string `json:"company" bson:"company"` - URL string `json:"url" bson:"URL"` - GithubToken string `json:"githubToken" bson:"githubToken"` - UserID int64 `json:"userID" bson:"userID"` - PersysToken string `json:"persysToken" bson:"persysToken"` - State string `json:"state" bson:"state"` - Status string `json:"status" bson:"status"` - CreatedAt string `json:"createdAt" bson:"createdAt"` - UpdatedAt string `json:"updatedAt" bson:"updatedAt"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + URL string `json:"url"` + GithubToken string `json:"githubToken"` + UserID int64 `json:"userID"` + PersysToken string `json:"persysToken"` + State string `json:"state"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type DBResponse struct { - Login string `json:"login" bson:"login"` - Name string `json:"name" bson:"name"` - Email string `json:"email" bson:"email"` - Company string `json:"company" bson:"company"` - URL string `json:"url" bson:"URL"` - GithubToken string `json:"githubToken" bson:"githubToken"` - UserID int64 `json:"userID" bson:"userID"` - PersysToken string `json:"persysToken" bson:"persysToken"` - State string `json:"state" bson:"state"` - Status string `json:"status" bson:"status"` - CreatedAt string `json:"createdAt" bson:"createdAt"` - UpdatedAt string `json:"updatedAt" bson:"updatedAt"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + URL string `json:"url"` + GithubToken string `json:"githubToken"` + UserID int64 `json:"userID"` + PersysToken string `json:"persysToken"` + State string `json:"state"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } -type UserResponse struct { - ID primitive.ObjectID `json:"id,omitempty" bson:"_id,omitempty"` - Name string `json:"name,omitempty" bson:"name,omitempty"` - Email string `json:"email,omitempty" bson:"email,omitempty"` - Role string `json:"role,omitempty" bson:"role,omitempty"` - CreatedAt time.Time `json:"created_at" bson:"created_at"` - UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` -} +// UserResponse (an ObjectID-keyed view, presumably meant for some future +// admin/listing endpoint) was defined but never referenced anywhere in +// the codebase — dropped rather than migrated. It also depended on +// mongo-driver's primitive.ObjectID, which the gateway no longer imports +// at all now that Postgres has replaced MongoDB. diff --git a/persys-gateway/routes/auth.routes.go b/persys-gateway/routes/auth.routes.go index 8f6ac0f..a514b61 100755 --- a/persys-gateway/routes/auth.routes.go +++ b/persys-gateway/routes/auth.routes.go @@ -6,7 +6,6 @@ import ( ) var ( - ctx *gin.Context scopes = []string{ "repo", "write:repo_hook", diff --git a/persys-gateway/routes/github.routes.go b/persys-gateway/routes/github.routes.go index 1885636..b9e7fed 100644 --- a/persys-gateway/routes/github.routes.go +++ b/persys-gateway/routes/github.routes.go @@ -10,8 +10,14 @@ type GithubRouteController struct { githubController controllers.GithubController } -func NewGithubRouteController(githubController controllers.GithubController) GithubRouteController { - return GithubRouteController{githubController: githubController} +// NewGithubRouteController now actually takes the AuthController it +// mounts behind. Previously it didn't — authController was left at its +// zero value, and Auth() only appeared to work because it silently +// depended on package-level globals set by the *other* auth controller's +// Setup() call. That accidental dependency is gone now that AuthController +// no longer uses package-level globals at all (see auth.controller.go). +func NewGithubRouteController(authController controllers.AuthController, githubController controllers.GithubController) GithubRouteController { + return GithubRouteController{authController: authController, githubController: githubController} } func (rc *GithubRouteController) GithubRoute(rg *gin.RouterGroup) { @@ -24,5 +30,4 @@ func (rc *GithubRouteController) GithubRoute(rg *gin.RouterGroup) { private.GET("/list/repos", rc.githubController.ListRepos()) private.GET("/set/webhook/:repoName", rc.githubController.SetWebhook()) private.GET("/set/accessToken/:accessToken", rc.githubController.SetAccessToken()) - } diff --git a/persys-gateway/routes/prow.routes.go b/persys-gateway/routes/prow.routes.go deleted file mode 100644 index 80f3b95..0000000 --- a/persys-gateway/routes/prow.routes.go +++ /dev/null @@ -1,79 +0,0 @@ -package routes - -import ( - "github.com/gin-gonic/gin" - "github.com/persys-dev/persys-cloud/persys-gateway/controllers" -) - -type ProwRouteController struct { - prowController *controllers.ProwController -} - -func NewProwRouteController(prowController *controllers.ProwController) ProwRouteController { - return ProwRouteController{prowController: prowController} -} - -func (rc *ProwRouteController) ProwRoute(rg *gin.RouterGroup) { - router := rg.Group("") - - router.GET("/health", rc.prowController.HealthCheckHandler()) - router.GET("/list", rc.prowController.ListHandler()) - router.GET("/clusters", rc.prowController.ListClustersHandler()) - router.GET("/clusters/:cluster_id", rc.prowController.GetClusterHandler()) - - workloads := router.Group("/workloads") - { - workloads.POST("/schedule", rc.prowController.ScheduleWorkloadHandler()) - workloads.GET("", rc.prowController.ListWorkloadsHandler()) - workloads.GET("/:id", rc.prowController.GetWorkloadHandler()) - workloads.DELETE("/:id", rc.prowController.DeleteWorkloadHandler()) - workloads.POST("/:id/retry", rc.prowController.RetryWorkloadHandler()) - } - - forgery := router.Group("/forgery") - { - forgery.POST("/projects/upsert", rc.prowController.UpsertProjectHandler()) - forgery.POST("/builds/trigger", rc.prowController.TriggerBuildHandler()) - forgery.POST("/webhooks/test", rc.prowController.TestWebhookHandler()) - forgery.GET("/pipeline/status", rc.prowController.ListPipelineStatusHandler()) - } - - nodes := router.Group("/nodes") - { - nodes.GET("", rc.prowController.ListNodesHandler()) - nodes.GET("/:id", rc.prowController.GetNodeHandler()) - nodes.POST("/:id/drain", rc.prowController.DrainNodeHandler()) - nodes.POST("/:id/undrain", rc.prowController.UndrainNodeHandler()) - nodes.POST("/:id/taint", rc.prowController.TaintNodeHandler()) - nodes.POST("/:id/untaint", rc.prowController.UntaintNodeHandler()) - nodes.POST("/:id/labels", rc.prowController.SetNodeLabelHandler()) - nodes.DELETE("/:id/labels", rc.prowController.DeleteNodeLabelHandler()) - } - - cluster := router.Group("/cluster") - { - cluster.GET("/metrics", rc.prowController.ClusterMetricsHandler()) - } - - clusters := router.Group("/clusters/:cluster_id") - { - clusters.POST("/workloads/schedule", rc.prowController.ScheduleWorkloadHandler()) - clusters.GET("/workloads", rc.prowController.ListWorkloadsHandler()) - clusters.GET("/workloads/:id", rc.prowController.GetWorkloadHandler()) - clusters.DELETE("/workloads/:id", rc.prowController.DeleteWorkloadHandler()) - clusters.POST("/workloads/:id/retry", rc.prowController.RetryWorkloadHandler()) - clusters.GET("/nodes", rc.prowController.ListNodesHandler()) - clusters.GET("/nodes/:id", rc.prowController.GetNodeHandler()) - clusters.POST("/nodes/:id/drain", rc.prowController.DrainNodeHandler()) - clusters.POST("/nodes/:id/undrain", rc.prowController.UndrainNodeHandler()) - clusters.POST("/nodes/:id/taint", rc.prowController.TaintNodeHandler()) - clusters.POST("/nodes/:id/untaint", rc.prowController.UntaintNodeHandler()) - clusters.POST("/nodes/:id/labels", rc.prowController.SetNodeLabelHandler()) - clusters.DELETE("/nodes/:id/labels", rc.prowController.DeleteNodeLabelHandler()) - clusters.GET("/cluster/metrics", rc.prowController.ClusterMetricsHandler()) - clusters.POST("/forgery/projects/upsert", rc.prowController.UpsertProjectHandler()) - clusters.POST("/forgery/builds/trigger", rc.prowController.TriggerBuildHandler()) - clusters.POST("/forgery/webhooks/test", rc.prowController.TestWebhookHandler()) - clusters.GET("/forgery/pipeline/status", rc.prowController.ListPipelineStatusHandler()) - } -} diff --git a/persys-gateway/services/auth.impl.service.go b/persys-gateway/services/auth.impl.service.go index 8590b4c..d4fa57e 100755 --- a/persys-gateway/services/auth.impl.service.go +++ b/persys-gateway/services/auth.impl.service.go @@ -2,62 +2,38 @@ package services import ( "context" - "errors" - "fmt" - "time" jwtlib "github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go/request" "github.com/gin-gonic/gin" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/store" "github.com/persys-dev/persys-cloud/persys-gateway/models" - - //"github.com/wpcodevo/golang-mongodb/utils" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" ) type AuthServiceImpl struct { - collection *mongo.Collection - ctx context.Context + store *store.Store + ctx context.Context + jwtSecret []byte } -func (uc *AuthServiceImpl) ReadUserData(ctx *gin.Context) (*models.DBResponse, error) { - - var result *models.DBResponse +func NewAuthService(st *store.Store, ctx context.Context, jwtSecret []byte) AuthService { + return &AuthServiceImpl{store: st, ctx: ctx, jwtSecret: jwtSecret} +} +func (uc *AuthServiceImpl) ReadUserData(ctx *gin.Context) (*models.DBResponse, error) { data, err := request.ParseFromRequest(ctx.Request, request.OAuth2Extractor, func(token *jwtlib.Token) (interface{}, error) { - b := []byte("unicornsAreAwesome") - return b, nil + return uc.jwtSecret, nil }) if err != nil { return nil, err } - user := data.Claims.(jwtlib.MapClaims) - UserID := user["UserID"].(float64) - res := uc.collection.FindOne(ctx, bson.M{"userID": UserID}) - if res.Err() == mongo.ErrNoDocuments { - return nil, res.Err() - } - err = res.Decode(&result) - if err != nil { - return nil, err - } - return result, nil + claims := data.Claims.(jwtlib.MapClaims) + userID := int64(claims["UserID"].(float64)) + return uc.store.FindUserByID(ctx.Request.Context(), userID) } func (uc *AuthServiceImpl) CliLogin(req *models.CliReq) (*models.DBResponse, error) { - res := uc.collection.FindOne(uc.ctx, bson.M{"state": req.State}) - - var result *models.DBResponse - - if res.Err() != mongo.ErrNoDocuments { - err := res.Decode(&result) - if err != nil { - return nil, err - } - return result, nil - } - return nil, res.Err() + return uc.store.FindUserByState(uc.ctx, req.State) } func (uc *AuthServiceImpl) CheckUser() { @@ -65,58 +41,20 @@ func (uc *AuthServiceImpl) CheckUser() { panic("implement me") } -func NewAuthService(collection *mongo.Collection, ctx context.Context) AuthService { - return &AuthServiceImpl{collection, ctx} -} - +// SignInUser creates or updates a user record. Delegates to +// store.UpsertUser, whose Postgres ON CONFLICT clause makes this +// atomic — no separate exists-check-then-branch, which is exactly what +// let the old Mongo implementation silently create duplicate user rows +// on every subsequent login (the insert ran unconditionally regardless +// of which branch was taken, relying on a unique index that was never +// actually created to catch it). func (uc *AuthServiceImpl) SignInUser(user *models.UserInput) (*models.DBResponse, error) { - - // check if a user exists - check := uc.collection.FindOne(uc.ctx, bson.M{"userID": user.UserID}) - - if check.Err() != mongo.ErrNoDocuments { - update := uc.collection.FindOneAndUpdate(uc.ctx, bson.M{"userID": user.UserID}, - bson.M{"$set": bson.M{ - "updatedAt": time.Now().String(), - "persysToken": user.PersysToken, - "githubToken": user.GithubToken, - "state": user.State, - }}) - fmt.Print(update) - } - - res, err := uc.collection.InsertOne(uc.ctx, &user) - - if err != nil { - if er, ok := err.(mongo.WriteException); ok && er.WriteErrors[0].Code == 11000 { - return nil, errors.New("user with that email already exist") - } - return nil, err - } - - // Create a unique index for the email field - //opt := options.Index() - //opt.SetUnique(true) - //index := mongo.IndexModel{Keys: bson.M{"email": 1}, Options: opt} - - //if _, err := uc.collection.Indexes().CreateOne(uc.ctx, index); err != nil { - // return nil, errors.New("could not create index for email") - //} - - var newUser *models.DBResponse - query := bson.M{"_id": res.InsertedID} - - err = uc.collection.FindOne(uc.ctx, query).Decode(&newUser) - if err != nil { - return nil, err - } - - return newUser, nil + return uc.store.UpsertUser(uc.ctx, user) } func (a *AuthServiceImpl) IsAuthenticated(ctx *gin.Context) bool { _, err := request.ParseFromRequest(ctx.Request, request.OAuth2Extractor, func(token *jwtlib.Token) (interface{}, error) { - return []byte("unicornsAreAwesome"), nil + return a.jwtSecret, nil }) return err == nil } diff --git a/persys-gateway/services/forgery.service.go b/persys-gateway/services/forgery.service.go new file mode 100644 index 0000000..fb51538 --- /dev/null +++ b/persys-gateway/services/forgery.service.go @@ -0,0 +1,183 @@ +package services + +import ( + "context" + "crypto/tls" + "fmt" + "time" + + "github.com/persys-dev/persys-cloud/persys-gateway/config" + forgeryv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/forgeryv1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/protobuf/proto" +) + +// ForgeryService is the gateway's client to persys-forgery's single fixed +// gRPC address. Split out from ClusterControlService (formerly +// ProwService), which pools and fails over across many scheduler +// replicas per cluster — forgery has no pool to fail over between, so +// giving it that shape would have been dishonest about what it actually +// is. +type ForgeryService struct { + cfg *config.Config + clientTLS *tls.Config +} + +func NewForgeryService(cfg *config.Config, clientTLS *tls.Config) *ForgeryService { + return &ForgeryService{cfg: cfg, clientTLS: clientTLS} +} + +func (s *ForgeryService) dial(ctx context.Context, timeout time.Duration) (*grpc.ClientConn, error) { + var forgeryTLS *tls.Config + if s.clientTLS != nil { + forgeryTLS = s.clientTLS.Clone() + } else { + forgeryTLS = &tls.Config{} + } + if serverName := s.cfg.Forgery.GRPCServerName; serverName != "" { + forgeryTLS.ServerName = serverName + } + + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + conn, err := grpc.DialContext(callCtx, s.cfg.Forgery.GRPCAddr, + grpc.WithTransportCredentials(credentials.NewTLS(forgeryTLS)), + grpc.WithBlock(), + ) + if err != nil { + return nil, fmt.Errorf("dial forgery %s: %w", s.cfg.Forgery.GRPCAddr, err) + } + return conn, nil +} + +func (s *ForgeryService) invokeForgeryRPC(ctx context.Context, call func(forgeryv1.ForgeryControlClient) (any, error)) (any, error) { + if ctx == nil { + ctx = context.Background() + } + conn, err := s.dial(ctx, 15*time.Second) + if err != nil { + return nil, err + } + defer conn.Close() + + client := forgeryv1.NewForgeryControlClient(conn) + callWithTrace := injectTraceContext(ctx) + resp, err := call(forgeryClientFromContext(client, callWithTrace)) + if err != nil { + return nil, err + } + return resp, nil +} + +func (s *ForgeryService) TriggerBuild(ctx context.Context, req *forgeryv1.TriggerBuildRequest) (*forgeryv1.OperationStatus, error) { + if req == nil { + return nil, fmt.Errorf("request is required") + } + resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { + return client.TriggerBuild(ctx, req) + }) + if err != nil { + return nil, err + } + return resp.(*forgeryv1.OperationStatus), nil +} + +func (s *ForgeryService) UpsertProject(ctx context.Context, req *forgeryv1.UpsertProjectRequest) (*forgeryv1.ProjectResponse, error) { + if req == nil { + return nil, fmt.Errorf("request is required") + } + resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { + return client.UpsertProject(ctx, req) + }) + if err != nil { + return nil, err + } + return resp.(*forgeryv1.ProjectResponse), nil +} + +func (s *ForgeryService) ForwardWebhookTest(ctx context.Context, req *forgeryv1.ForwardWebhookRequest) (*forgeryv1.ForwardWebhookResponse, error) { + if req == nil { + return nil, fmt.Errorf("request is required") + } + req.Verified = true + resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { + return client.ForwardWebhook(ctx, req) + }) + if err != nil { + return nil, err + } + return resp.(*forgeryv1.ForwardWebhookResponse), nil +} + +func (s *ForgeryService) ListPipelineStatus(ctx context.Context, req *forgeryv1.ListPipelineStatusRequest) (*forgeryv1.ListPipelineStatusResponse, error) { + if req == nil { + req = &forgeryv1.ListPipelineStatusRequest{} + } + resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { + return client.ListPipelineStatus(ctx, req) + }) + if err != nil { + return nil, err + } + return resp.(*forgeryv1.ListPipelineStatusResponse), nil +} + +// InvokeDynamic implements grpcbridge.Invoker. clusterID/sessionKey/ +// workloadKey are accepted to satisfy the interface but unused — forgery +// has no per-cluster routing today. If that changes, this is the one +// place that grows pool logic; the bridge and its callers don't need to +// know either way. +func (s *ForgeryService) InvokeDynamic(ctx context.Context, _, _, _, fullMethod string, in, out proto.Message) error { + conn, err := s.dial(ctx, 15*time.Second) + if err != nil { + return err + } + defer conn.Close() + return conn.Invoke(injectTraceContext(ctx), fullMethod, in, out) +} + +// DialForReflection implements grpcbridge.ReflectionSource. +func (s *ForgeryService) DialForReflection(ctx context.Context, _ string) (*grpc.ClientConn, error) { + return s.dial(ctx, 10*time.Second) +} + +type forgeryClientWithContext struct { + forgeryv1.ForgeryControlClient + ctx context.Context +} + +func forgeryClientFromContext(client forgeryv1.ForgeryControlClient, ctx context.Context) forgeryv1.ForgeryControlClient { + return &forgeryClientWithContext{ForgeryControlClient: client, ctx: ctx} +} + +func (c *forgeryClientWithContext) ForwardWebhook(_ context.Context, req *forgeryv1.ForwardWebhookRequest, opts ...grpc.CallOption) (*forgeryv1.ForwardWebhookResponse, error) { + return c.ForgeryControlClient.ForwardWebhook(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) UpsertProject(_ context.Context, req *forgeryv1.UpsertProjectRequest, opts ...grpc.CallOption) (*forgeryv1.ProjectResponse, error) { + return c.ForgeryControlClient.UpsertProject(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) GetProject(_ context.Context, req *forgeryv1.GetProjectRequest, opts ...grpc.CallOption) (*forgeryv1.ProjectResponse, error) { + return c.ForgeryControlClient.GetProject(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) ListProjects(_ context.Context, req *forgeryv1.ListProjectsRequest, opts ...grpc.CallOption) (*forgeryv1.ListProjectsResponse, error) { + return c.ForgeryControlClient.ListProjects(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) DeleteProject(_ context.Context, req *forgeryv1.DeleteProjectRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { + return c.ForgeryControlClient.DeleteProject(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) StoreGitHubCredential(_ context.Context, req *forgeryv1.StoreGitHubCredentialRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { + return c.ForgeryControlClient.StoreGitHubCredential(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) ListUserRepositories(_ context.Context, req *forgeryv1.ListUserRepositoriesRequest, opts ...grpc.CallOption) (*forgeryv1.ListUserRepositoriesResponse, error) { + return c.ForgeryControlClient.ListUserRepositories(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) RegisterWebhook(_ context.Context, req *forgeryv1.RegisterWebhookRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { + return c.ForgeryControlClient.RegisterWebhook(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) TriggerBuild(_ context.Context, req *forgeryv1.TriggerBuildRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { + return c.ForgeryControlClient.TriggerBuild(c.ctx, req, opts...) +} +func (c *forgeryClientWithContext) ListPipelineStatus(_ context.Context, req *forgeryv1.ListPipelineStatusRequest, opts ...grpc.CallOption) (*forgeryv1.ListPipelineStatusResponse, error) { + return c.ForgeryControlClient.ListPipelineStatus(c.ctx, req, opts...) +} diff --git a/persys-gateway/services/github.impl.service.go b/persys-gateway/services/github.impl.service.go index c920294..feb141f 100644 --- a/persys-gateway/services/github.impl.service.go +++ b/persys-gateway/services/github.impl.service.go @@ -10,7 +10,6 @@ import ( "github.com/persys-dev/persys-cloud/persys-gateway/config" forgeryv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/forgeryv1" "github.com/persys-dev/persys-cloud/persys-gateway/models" - "go.mongodb.org/mongo-driver/mongo" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) @@ -20,7 +19,11 @@ type GithubServiceImpl struct { tlsClient *tls.Config } -func NewGithubService(_ *mongo.Collection, _ context.Context, cfg *config.Config, tlsClient *tls.Config) GithubService { +// NewGithubService previously took an unused *mongo.Collection parameter +// (named `_`, never read) — dropped. This service has never touched a +// database directly; every operation delegates to persys-forgery over +// gRPC, which owns repository/credential data itself. +func NewGithubService(cfg *config.Config, tlsClient *tls.Config) GithubService { return &GithubServiceImpl{cfg: cfg, tlsClient: tlsClient} } diff --git a/persys-gateway/services/scheduler.service.go b/persys-gateway/services/scheduler.service.go index 140b186..110f5d5 100644 --- a/persys-gateway/services/scheduler.service.go +++ b/persys-gateway/services/scheduler.service.go @@ -10,15 +10,15 @@ import ( "os" ) -type ProwService struct { +type ClusterControlService struct { config *config.Config clientTLS *tls.Config serverTLS *tls.Config schedulerPool *SchedulerPoolManager } -func NewProwService(cfg *config.Config) *ProwService { - service := &ProwService{config: cfg} +func NewClusterControlService(cfg *config.Config) *ClusterControlService { + service := &ClusterControlService{config: cfg} if err := service.loadTLSConfigs(); err != nil { panic(fmt.Sprintf("failed to load TLS configs: %v", err)) @@ -33,11 +33,11 @@ func NewProwService(cfg *config.Config) *ProwService { return service } -func (s *ProwService) Start(ctx context.Context) { +func (s *ClusterControlService) Start(ctx context.Context) { s.schedulerPool.Start(ctx) } -func (s *ProwService) loadTLSConfigs() error { +func (s *ClusterControlService) loadTLSConfigs() error { cert, err := tls.LoadX509KeyPair(s.config.TLS.CertPath, s.config.TLS.KeyPath) if err != nil { return fmt.Errorf("failed to load client certificate: %w", err) @@ -58,24 +58,24 @@ func (s *ProwService) loadTLSConfigs() error { return nil } -func (s *ProwService) DiscoverAndPrintSchedulers() { +func (s *ClusterControlService) DiscoverAndPrintSchedulers() { s.schedulerPool.ForceDiscover(context.Background()) } -func (s *ProwService) DiscoverSchedulers(_ string) error { +func (s *ClusterControlService) DiscoverSchedulers(_ string) error { s.schedulerPool.ForceDiscover(context.Background()) return nil } -func (s *ProwService) GetSchedulerAddress() string { +func (s *ClusterControlService) GetSchedulerAddress() string { inst, err := s.schedulerPool.OrderedSchedulers(s.schedulerPool.DefaultClusterID(), "", "") if err != nil || len(inst) == 0 { - return s.config.Prow.SchedulerAddr + return s.config.LegacyScheduler.FallbackAddr } return inst[0].Address } -func (s *ProwService) GetSchedulerAddresses() []string { +func (s *ClusterControlService) GetSchedulerAddresses() []string { clusterID := s.schedulerPool.DefaultClusterID() addrs := make([]string, 0) for _, c := range s.schedulerPool.Snapshot() { @@ -89,8 +89,8 @@ func (s *ProwService) GetSchedulerAddresses() []string { return addrs } -func (s *ProwService) IsProxyEnabled() bool { - return s.config.Prow.EnableProxy +func (s *ClusterControlService) IsProxyEnabled() bool { + return s.config.LegacyScheduler.ProxyEnabled } func IsSchedulerUnavailable(err error) bool { @@ -107,10 +107,10 @@ func IsUnknownCluster(err error) bool { return errors.Is(err, ErrUnknownCluster) } -func (s *ProwService) SnapshotClusters() []Cluster { +func (s *ClusterControlService) SnapshotClusters() []Cluster { return s.schedulerPool.Snapshot() } -func (s *ProwService) DefaultClusterID() string { +func (s *ClusterControlService) DefaultClusterID() string { return s.schedulerPool.DefaultClusterID() } diff --git a/persys-gateway/services/scheduler_grpc.service.go b/persys-gateway/services/scheduler_grpc.service.go index 5504054..09690b3 100644 --- a/persys-gateway/services/scheduler_grpc.service.go +++ b/persys-gateway/services/scheduler_grpc.service.go @@ -2,20 +2,19 @@ package services import ( "context" - "crypto/tls" "fmt" "strings" "time" controlv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/controlv1" - forgeryv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/forgeryv1" "go.opentelemetry.io/otel" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" ) -func (s *ProwService) ApplyWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.ApplyWorkloadRequest) (*controlv1.ApplyWorkloadResponse, error) { +func (s *ClusterControlService) ApplyWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.ApplyWorkloadRequest) (*controlv1.ApplyWorkloadResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.ApplyWorkload(ctx, req) }) @@ -25,7 +24,7 @@ func (s *ProwService) ApplyWorkload(ctx context.Context, clusterID, sessionKey, return resp.(*controlv1.ApplyWorkloadResponse), nil } -func (s *ProwService) ListNodes(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.ListNodesRequest) (*controlv1.ListNodesResponse, error) { +func (s *ClusterControlService) ListNodes(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.ListNodesRequest) (*controlv1.ListNodesResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.ListNodes(ctx, req) }) @@ -35,7 +34,7 @@ func (s *ProwService) ListNodes(ctx context.Context, clusterID, sessionKey, work return resp.(*controlv1.ListNodesResponse), nil } -func (s *ProwService) ListWorkloads(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.ListWorkloadsRequest) (*controlv1.ListWorkloadsResponse, error) { +func (s *ClusterControlService) ListWorkloads(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.ListWorkloadsRequest) (*controlv1.ListWorkloadsResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.ListWorkloads(ctx, req) }) @@ -45,7 +44,7 @@ func (s *ProwService) ListWorkloads(ctx context.Context, clusterID, sessionKey, return resp.(*controlv1.ListWorkloadsResponse), nil } -func (s *ProwService) GetWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.GetWorkloadRequest) (*controlv1.GetWorkloadResponse, error) { +func (s *ClusterControlService) GetWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.GetWorkloadRequest) (*controlv1.GetWorkloadResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.GetWorkload(ctx, req) }) @@ -55,7 +54,7 @@ func (s *ProwService) GetWorkload(ctx context.Context, clusterID, sessionKey, wo return resp.(*controlv1.GetWorkloadResponse), nil } -func (s *ProwService) DeleteWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.DeleteWorkloadRequest) (*controlv1.DeleteWorkloadResponse, error) { +func (s *ClusterControlService) DeleteWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.DeleteWorkloadRequest) (*controlv1.DeleteWorkloadResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.DeleteWorkload(ctx, req) }) @@ -65,7 +64,7 @@ func (s *ProwService) DeleteWorkload(ctx context.Context, clusterID, sessionKey, return resp.(*controlv1.DeleteWorkloadResponse), nil } -func (s *ProwService) RetryWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.RetryWorkloadRequest) (*controlv1.RetryWorkloadResponse, error) { +func (s *ClusterControlService) RetryWorkload(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.RetryWorkloadRequest) (*controlv1.RetryWorkloadResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.RetryWorkload(ctx, req) }) @@ -75,7 +74,7 @@ func (s *ProwService) RetryWorkload(ctx context.Context, clusterID, sessionKey, return resp.(*controlv1.RetryWorkloadResponse), nil } -func (s *ProwService) DrainNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.DrainNodeRequest) (*controlv1.DrainNodeResponse, error) { +func (s *ClusterControlService) DrainNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.DrainNodeRequest) (*controlv1.DrainNodeResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.DrainNode(ctx, req) }) @@ -85,7 +84,7 @@ func (s *ProwService) DrainNode(ctx context.Context, clusterID, sessionKey, work return resp.(*controlv1.DrainNodeResponse), nil } -func (s *ProwService) UndrainNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.UndrainNodeRequest) (*controlv1.UndrainNodeResponse, error) { +func (s *ClusterControlService) UndrainNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.UndrainNodeRequest) (*controlv1.UndrainNodeResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.UndrainNode(ctx, req) }) @@ -95,7 +94,7 @@ func (s *ProwService) UndrainNode(ctx context.Context, clusterID, sessionKey, wo return resp.(*controlv1.UndrainNodeResponse), nil } -func (s *ProwService) TaintNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.TaintNodeRequest) (*controlv1.TaintNodeResponse, error) { +func (s *ClusterControlService) TaintNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.TaintNodeRequest) (*controlv1.TaintNodeResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.TaintNode(ctx, req) }) @@ -105,7 +104,7 @@ func (s *ProwService) TaintNode(ctx context.Context, clusterID, sessionKey, work return resp.(*controlv1.TaintNodeResponse), nil } -func (s *ProwService) UntaintNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.UntaintNodeRequest) (*controlv1.UntaintNodeResponse, error) { +func (s *ClusterControlService) UntaintNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.UntaintNodeRequest) (*controlv1.UntaintNodeResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.UntaintNode(ctx, req) }) @@ -115,7 +114,7 @@ func (s *ProwService) UntaintNode(ctx context.Context, clusterID, sessionKey, wo return resp.(*controlv1.UntaintNodeResponse), nil } -func (s *ProwService) SetNodeLabel(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.SetNodeLabelRequest) (*controlv1.SetNodeLabelResponse, error) { +func (s *ClusterControlService) SetNodeLabel(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.SetNodeLabelRequest) (*controlv1.SetNodeLabelResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.SetNodeLabel(ctx, req) }) @@ -125,7 +124,7 @@ func (s *ProwService) SetNodeLabel(ctx context.Context, clusterID, sessionKey, w return resp.(*controlv1.SetNodeLabelResponse), nil } -func (s *ProwService) DeleteNodeLabel(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.DeleteNodeLabelRequest) (*controlv1.DeleteNodeLabelResponse, error) { +func (s *ClusterControlService) DeleteNodeLabel(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.DeleteNodeLabelRequest) (*controlv1.DeleteNodeLabelResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.DeleteNodeLabel(ctx, req) }) @@ -135,7 +134,7 @@ func (s *ProwService) DeleteNodeLabel(ctx context.Context, clusterID, sessionKey return resp.(*controlv1.DeleteNodeLabelResponse), nil } -func (s *ProwService) GetNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.GetNodeRequest) (*controlv1.GetNodeResponse, error) { +func (s *ClusterControlService) GetNode(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.GetNodeRequest) (*controlv1.GetNodeResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.GetNode(ctx, req) }) @@ -145,7 +144,7 @@ func (s *ProwService) GetNode(ctx context.Context, clusterID, sessionKey, worklo return resp.(*controlv1.GetNodeResponse), nil } -func (s *ProwService) GetClusterSummary(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.GetClusterSummaryRequest) (*controlv1.GetClusterSummaryResponse, error) { +func (s *ClusterControlService) GetClusterSummary(ctx context.Context, clusterID, sessionKey, workloadKey string, req *controlv1.GetClusterSummaryRequest) (*controlv1.GetClusterSummaryResponse, error) { resp, err := s.invokeControlRPC(ctx, clusterID, sessionKey, workloadKey, func(client controlv1.AgentControlClient) (any, error) { return client.GetClusterSummary(ctx, req) }) @@ -155,7 +154,7 @@ func (s *ProwService) GetClusterSummary(ctx context.Context, clusterID, sessionK return resp.(*controlv1.GetClusterSummaryResponse), nil } -func (s *ProwService) invokeControlRPC(ctx context.Context, clusterID, sessionKey, workloadKey string, call func(controlv1.AgentControlClient) (any, error)) (any, error) { +func (s *ClusterControlService) invokeControlRPC(ctx context.Context, clusterID, sessionKey, workloadKey string, call func(controlv1.AgentControlClient) (any, error)) (any, error) { if clusterID == "" { clusterID = s.schedulerPool.DefaultClusterID() } @@ -197,94 +196,80 @@ func (s *ProwService) invokeControlRPC(ctx context.Context, clusterID, sessionKe return nil, lastErr } -func (s *ProwService) TriggerBuild(ctx context.Context, req *forgeryv1.TriggerBuildRequest) (*forgeryv1.OperationStatus, error) { - if req == nil { - return nil, fmt.Errorf("request is required") - } - resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { - return client.TriggerBuild(ctx, req) - }) - if err != nil { - return nil, err +// InvokeDynamic implements grpcbridge.Invoker. It reuses the exact same +// candidate ranking, dial, and failover logic as invokeControlRPC above — +// this is deliberately NOT a separate connection-selection path. The only +// difference from the typed methods (ApplyWorkload, ListNodes, etc.) is +// that the request/response are dynamicpb messages built from reflection +// instead of generated Go types, so the call goes through conn.Invoke +// with a full method name string rather than a generated client method. +func (s *ClusterControlService) InvokeDynamic(ctx context.Context, clusterID, sessionKey, workloadKey, fullMethod string, in, out proto.Message) error { + if clusterID == "" { + clusterID = s.schedulerPool.DefaultClusterID() } - return resp.(*forgeryv1.OperationStatus), nil -} -func (s *ProwService) UpsertProject(ctx context.Context, req *forgeryv1.UpsertProjectRequest) (*forgeryv1.ProjectResponse, error) { - if req == nil { - return nil, fmt.Errorf("request is required") - } - resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { - return client.UpsertProject(ctx, req) - }) + candidates, err := s.schedulerPool.OrderedSchedulers(clusterID, sessionKey, workloadKey) if err != nil { - return nil, err + return fmt.Errorf("select scheduler candidates for cluster %q: %w", clusterID, err) } - return resp.(*forgeryv1.ProjectResponse), nil -} -func (s *ProwService) ForwardWebhookTest(ctx context.Context, req *forgeryv1.ForwardWebhookRequest) (*forgeryv1.ForwardWebhookResponse, error) { - if req == nil { - return nil, fmt.Errorf("request is required") - } - req.Verified = true - resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { - return client.ForwardWebhook(ctx, req) - }) - if err != nil { - return nil, err - } - return resp.(*forgeryv1.ForwardWebhookResponse), nil -} + var lastErr error + for _, target := range candidates { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + conn, dialErr := grpc.DialContext(callCtx, target.Address, + grpc.WithTransportCredentials(credentials.NewTLS(s.clientTLS)), + grpc.WithBlock(), + ) + cancel() + if dialErr != nil { + s.schedulerPool.MarkUnhealthy(clusterID, target.Address) + lastErr = dialErr + continue + } -func (s *ProwService) ListPipelineStatus(ctx context.Context, req *forgeryv1.ListPipelineStatusRequest) (*forgeryv1.ListPipelineStatusResponse, error) { - if req == nil { - req = &forgeryv1.ListPipelineStatusRequest{} - } - resp, err := s.invokeForgeryRPC(ctx, func(client forgeryv1.ForgeryControlClient) (any, error) { - return client.ListPipelineStatus(ctx, req) - }) - if err != nil { - return nil, err + callWithTrace := injectTraceContext(ctx) + rpcErr := conn.Invoke(callWithTrace, fullMethod, in, out) + _ = conn.Close() + if rpcErr != nil { + s.schedulerPool.MarkUnhealthy(clusterID, target.Address) + lastErr = rpcErr + continue + } + return nil } - return resp.(*forgeryv1.ListPipelineStatusResponse), nil -} -func (s *ProwService) invokeForgeryRPC(ctx context.Context, call func(forgeryv1.ForgeryControlClient) (any, error)) (any, error) { - if ctx == nil { - ctx = context.Background() + if lastErr == nil { + lastErr = ErrNoHealthySchedulers } - callCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() + return lastErr +} - var forgeryTLS *tls.Config - if s.clientTLS != nil { - forgeryTLS = s.clientTLS.Clone() - } else { - forgeryTLS = &tls.Config{} +// DialForReflection implements grpcbridge.ReflectionSource. Any one +// healthy candidate is sufficient for descriptor discovery — reflection +// responses are identical across replicas of the same deployed version — +// so this deliberately skips the retry loop above: a failed reflection +// dial just means "try again on the next refresh tick" (see +// grpcbridge.Bridge.refreshLoop), not "fail the request." +func (s *ClusterControlService) DialForReflection(ctx context.Context, clusterID string) (*grpc.ClientConn, error) { + if clusterID == "" { + clusterID = s.schedulerPool.DefaultClusterID() } - if serverName := s.config.Forgery.GRPCServerName; serverName != "" { - forgeryTLS.ServerName = serverName + candidates, err := s.schedulerPool.OrderedSchedulers(clusterID, "", "") + if err != nil || len(candidates) == 0 { + return nil, fmt.Errorf("no scheduler candidates for cluster %q: %w", clusterID, err) } - - conn, err := grpc.DialContext(callCtx, s.config.Forgery.GRPCAddr, - grpc.WithTransportCredentials(credentials.NewTLS(forgeryTLS)), + return grpc.DialContext(ctx, candidates[0].Address, + grpc.WithTransportCredentials(credentials.NewTLS(s.clientTLS)), grpc.WithBlock(), ) - if err != nil { - return nil, fmt.Errorf("dial forgery %s: %w", s.config.Forgery.GRPCAddr, err) - } - defer conn.Close() - - client := forgeryv1.NewForgeryControlClient(conn) - callWithTrace := injectTraceContext(ctx) - resp, err := call(forgeryClientFromContext(client, callWithTrace)) - if err != nil { - return nil, err - } - return resp, nil } +// Forgery methods (TriggerBuild, UpsertProject, ForwardWebhookTest, +// ListPipelineStatus, invokeForgeryRPC) moved to services/forgery.service.go +// as ForgeryService: forgery is a single fixed address with no pool or +// failover, and sharing ProwService/ClusterControlService's shape here +// was never honest about that difference. + func injectTraceContext(ctx context.Context) context.Context { md, ok := metadata.FromOutgoingContext(ctx) if !ok { @@ -362,42 +347,4 @@ func (c *controlClientWithContext) Heartbeat(_ context.Context, req *controlv1.H return c.AgentControlClient.Heartbeat(c.ctx, req, opts...) } -type forgeryClientWithContext struct { - forgeryv1.ForgeryControlClient - ctx context.Context -} - -func forgeryClientFromContext(client forgeryv1.ForgeryControlClient, ctx context.Context) forgeryv1.ForgeryControlClient { - return &forgeryClientWithContext{ForgeryControlClient: client, ctx: ctx} -} - -func (c *forgeryClientWithContext) ForwardWebhook(_ context.Context, req *forgeryv1.ForwardWebhookRequest, opts ...grpc.CallOption) (*forgeryv1.ForwardWebhookResponse, error) { - return c.ForgeryControlClient.ForwardWebhook(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) UpsertProject(_ context.Context, req *forgeryv1.UpsertProjectRequest, opts ...grpc.CallOption) (*forgeryv1.ProjectResponse, error) { - return c.ForgeryControlClient.UpsertProject(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) GetProject(_ context.Context, req *forgeryv1.GetProjectRequest, opts ...grpc.CallOption) (*forgeryv1.ProjectResponse, error) { - return c.ForgeryControlClient.GetProject(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) ListProjects(_ context.Context, req *forgeryv1.ListProjectsRequest, opts ...grpc.CallOption) (*forgeryv1.ListProjectsResponse, error) { - return c.ForgeryControlClient.ListProjects(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) DeleteProject(_ context.Context, req *forgeryv1.DeleteProjectRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { - return c.ForgeryControlClient.DeleteProject(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) StoreGitHubCredential(_ context.Context, req *forgeryv1.StoreGitHubCredentialRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { - return c.ForgeryControlClient.StoreGitHubCredential(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) ListUserRepositories(_ context.Context, req *forgeryv1.ListUserRepositoriesRequest, opts ...grpc.CallOption) (*forgeryv1.ListUserRepositoriesResponse, error) { - return c.ForgeryControlClient.ListUserRepositories(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) RegisterWebhook(_ context.Context, req *forgeryv1.RegisterWebhookRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { - return c.ForgeryControlClient.RegisterWebhook(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) TriggerBuild(_ context.Context, req *forgeryv1.TriggerBuildRequest, opts ...grpc.CallOption) (*forgeryv1.OperationStatus, error) { - return c.ForgeryControlClient.TriggerBuild(c.ctx, req, opts...) -} -func (c *forgeryClientWithContext) ListPipelineStatus(_ context.Context, req *forgeryv1.ListPipelineStatusRequest, opts ...grpc.CallOption) (*forgeryv1.ListPipelineStatusResponse, error) { - return c.ForgeryControlClient.ListPipelineStatus(c.ctx, req, opts...) -} +// forgeryClientWithContext moved to services/forgery.service.go. diff --git a/persys-gateway/services/scheduler_pool.go b/persys-gateway/services/scheduler_pool.go index bb5d1d5..c062041 100644 --- a/persys-gateway/services/scheduler_pool.go +++ b/persys-gateway/services/scheduler_pool.go @@ -250,8 +250,8 @@ func (m *SchedulerPoolManager) discoverAndMerge(ctx context.Context) { } func (m *SchedulerPoolManager) discoverSchedulers(ctx context.Context) ([]string, string, error) { - service := strings.TrimSpace(m.cfg.Prow.DiscoverySvc) - domain := strings.TrimSpace(m.cfg.Prow.DiscoveryDomain) + service := strings.TrimSpace(m.cfg.LegacyScheduler.DiscoverySvc) + domain := strings.TrimSpace(m.cfg.LegacyScheduler.DiscoveryDomain) if service == "" || domain == "" { return nil, "config", fmt.Errorf("discovery service/domain not configured") } diff --git a/persys-gateway/services/webhook.service.go b/persys-gateway/services/webhook.service.go index eae3350..eeaf95e 100644 --- a/persys-gateway/services/webhook.service.go +++ b/persys-gateway/services/webhook.service.go @@ -16,10 +16,8 @@ import ( "github.com/persys-dev/persys-cloud/persys-gateway/config" forgeryv1 "github.com/persys-dev/persys-cloud/persys-gateway/internal/forgeryv1" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/store" "github.com/persys-dev/persys-cloud/persys-gateway/models" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/propagation" "google.golang.org/grpc" @@ -34,7 +32,7 @@ type WebhookService interface { type webhookService struct { cfg *config.Config tlsConfig *tls.Config - collection *mongo.Collection + store *store.Store replayTTL time.Duration baseBackoff time.Duration retries int @@ -69,7 +67,7 @@ type githubPushEnvelope struct { } `json:"repository"` } -func NewWebhookService(cfg *config.Config, tlsClient *tls.Config, collection *mongo.Collection) (WebhookService, error) { +func NewWebhookService(cfg *config.Config, tlsClient *tls.Config, st *store.Store) (WebhookService, error) { replayTTL, err := time.ParseDuration(cfg.Webhook.ReplayTTL) if err != nil { return nil, fmt.Errorf("invalid webhook.replay_ttl: %w", err) @@ -85,7 +83,7 @@ func NewWebhookService(cfg *config.Config, tlsClient *tls.Config, collection *mo return &webhookService{ cfg: cfg, tlsConfig: tlsClient, - collection: collection, + store: st, replayTTL: replayTTL, baseBackoff: baseBackoff, retries: cfg.Webhook.ForwardRetries, @@ -197,7 +195,7 @@ func (w *webhookService) HandleGitHubWebhook(ctx context.Context, headers http.H } func (w *webhookService) persist(ctx context.Context, event models.WebhookEvent) { - if w.collection == nil { + if w.store == nil { return } now := time.Now().UTC() @@ -208,9 +206,7 @@ func (w *webhookService) persist(ctx context.Context, event models.WebhookEvent) event.ReceivedAt = now } - update := bson.M{"$set": event, "$setOnInsert": bson.M{"delivery_id": event.DeliveryID, "received_at": event.ReceivedAt}} - _, err := w.collection.UpdateOne(ctx, bson.M{"delivery_id": event.DeliveryID}, update, options.Update().SetUpsert(true)) - if err != nil { + if err := w.store.UpsertWebhookEvent(ctx, &event); err != nil { log.Printf("failed to persist webhook metadata delivery=%s err=%v", event.DeliveryID, err) } } diff --git a/persys-gateway/tests/auth_test.go b/persys-gateway/tests/auth_test.go index 987cadf..f8114a6 100644 --- a/persys-gateway/tests/auth_test.go +++ b/persys-gateway/tests/auth_test.go @@ -3,51 +3,57 @@ package tests import ( "context" "crypto/tls" + "net/http" + "net/http/httptest" + "os" + "testing" + "github.com/gin-gonic/gin" "github.com/persys-dev/persys-cloud/persys-gateway/config" "github.com/persys-dev/persys-cloud/persys-gateway/controllers" + "github.com/persys-dev/persys-cloud/persys-gateway/internal/store" "github.com/persys-dev/persys-cloud/persys-gateway/routes" "github.com/persys-dev/persys-cloud/persys-gateway/services" "github.com/stretchr/testify/assert" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" - "net/http" - "net/http/httptest" - "testing" ) var ( - scopes = []string{ - "repo", - "write:repo_hook", - "user", - // You have to select your own scope from here -> https://developer.github.com/v3/oauth/#scopes - } redirectUri = "http://localhost:8551/auth" - GithubCollection *mongo.Collection - AuthCollection *mongo.Collection AuthRouteController routes.AuthRouteController ctx = context.TODO() ) +// TestAuthRoute previously connected to a hardcoded MongoDB Atlas cluster +// with a username/password committed directly in this file. That +// credential was live and public in the repo; if this is your database, +// rotate it now regardless of this fix. The test now requires +// PERSYS_TEST_POSTGRES_DSN to be set and skips (not fails) otherwise, so +// running the suite doesn't require, or leak, real database credentials. func TestAuthRoute(t *testing.T) { - mongoconn := options.Client().ApplyURI("mongodb+srv://miladhzz:hXBfZeTBHvLbu0Fy@cluster0.nlik4mb.mongodb.net/?retryWrites=true&w=majority") - mongoclient, err := mongo.Connect(ctx, mongoconn) + dsn := os.Getenv("PERSYS_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("PERSYS_TEST_POSTGRES_DSN not set — skipping integration test that requires a real Postgres instance") + } + + db, err := store.New(ctx, dsn, 5) if err != nil { t.Fatal(err) } - err = mongoclient.Ping(context.Background(), nil) - if err != nil { + defer db.Close() + if err := db.Migrate(ctx); err != nil { t.Fatal(err) } - GithubCollection = mongoclient.Database("persys-gateway").Collection("repos") - AuthCollection = mongoclient.Database("persys-gateway").Collection("users") gin.SetMode(gin.TestMode) - githubService := services.NewGithubService(GithubCollection, ctx, &config.Config{}, &tls.Config{}) - authService := services.NewAuthService(AuthCollection, ctx) - authController := controllers.NewAuthController(authService, ctx, githubService, AuthCollection, AuthCollection) + testJWTSecret := []byte("test-only-secret-not-used-in-production") + + githubService := services.NewGithubService(&config.Config{}, &tls.Config{}) + authService := services.NewAuthService(db, ctx, testJWTSecret) + authController := controllers.NewAuthController( + authService, ctx, githubService, db, + "test-client-id", "test-client-secret", testJWTSecret, + ) AuthRouteController = routes.NewAuthRouteController(authController, redirectUri) router := gin.Default() diff --git a/persys-gateway/utils/token.go b/persys-gateway/utils/token.go index d071714..e89cb15 100644 --- a/persys-gateway/utils/token.go +++ b/persys-gateway/utils/token.go @@ -3,25 +3,26 @@ package utils import ( "crypto/rand" "encoding/base64" + "time" + jwtlib "github.com/dgrijalva/jwt-go" "github.com/golang/glog" "github.com/google/go-github/github" - "time" ) -func GenerateToken(user *github.User) (tok string, err error) { - // Create the token +// GenerateToken signs a session token for user with secret. secret comes +// from config.Config.App.JWTSecret (env-sourced, never hardcoded — see +// config/config.go). Previously this was a literal string, +// "unicornsAreAwesome", committed in a public repo; anyone with the +// source could mint a valid token for any user ID. +func GenerateToken(user *github.User, secret []byte) (tok string, err error) { token := jwtlib.New(jwtlib.GetSigningMethod("HS256")) - // Set some claims token.Claims = jwtlib.MapClaims{ "Name": user.Login, "UserID": user.ID, "exp": time.Now().Add(time.Hour * 1).Unix(), } - // Sign and get the complete encoded token as a string - mySuperSecretPassword := "unicornsAreAwesome" - - tokenString, err := token.SignedString([]byte(mySuperSecretPassword)) + tokenString, err := token.SignedString(secret) if err != nil { return "", err }