feat(dashboard): PoC Oauth2 auth

This commit is contained in:
Peter 2025-02-03 09:57:05 +01:00
parent 89b682935b
commit 0fccef973f
Signed by: prskr
GPG key ID: F56BED6903BC5E37
53 changed files with 1914 additions and 331 deletions

View file

@ -61,7 +61,7 @@ k8s_resource(
k8s_resource(
objects=["gateway-sample:APIGateway:supabase-demo"],
extra_pod_selectors={"app.kubernetes.io/component": "api-gateway"},
port_forwards=[8000, 19000],
port_forwards=[3000, 8000, 19000],
new_name='API Gateway',
resource_deps=[
'supabase-controller-manager'
@ -72,7 +72,7 @@ k8s_resource(
objects=["dashboard-sample:Dashboard:supabase-demo"],
extra_pod_selectors={"app.kubernetes.io/component": "dashboard", "app.kubernetes.io/name": "studio"},
discovery_strategy="selectors-only",
port_forwards=[3000],
# port_forwards=[3000],
new_name='Dashboard',
resource_deps=[
'supabase-controller-manager'

View file

@ -46,14 +46,111 @@ type EnvoySpec struct {
ControlPlane *ControlPlaneSpec `json:"controlPlane"`
// WorkloadTemplate - customize the Envoy deployment
WorkloadTemplate *WorkloadTemplate `json:"workloadTemplate,omitempty"`
// DisableIPv6 - disable IPv6 for the Envoy instance
// this will force Envoy to use IPv4 for upstream hosts (mostly for the OAuth2 token endpoint)
DisableIPv6 bool `json:"disableIPv6,omitempty"`
}
type TlsCertRef struct {
SecretName string `json:"secretName"`
// ServerCertKey - key in the secret that contains the server certificate
// +kubebuilder:default="tls.crt"
ServerCertKey string `json:"serverCertKey"`
// ServerKeyKey - key in the secret that contains the server private key
// +kubebuilder:default="tls.key"
ServerKeyKey string `json:"serverKeyKey"`
// CaCertKey - key in the secret that contains the CA certificate
// +kubebuilder:default="ca.crt"
CaCertKey string `json:"caCertKey,omitempty"`
}
type EndpointTlsSpec struct {
Cert *TlsCertRef `json:"cert"`
}
type ApiEndpointSpec struct {
// JWKSSelector - selector where the JWKS can be retrieved from to enable the API gateway to validate JWTs
JWKSSelector *corev1.SecretKeySelector `json:"jwks"`
// TLS - enable and configure TLS for the API endpoint
TLS *EndpointTlsSpec `json:"tls,omitempty"`
}
type DashboardEndpointSpec struct{}
func (s *ApiEndpointSpec) TLSSpec() *EndpointTlsSpec {
if s == nil {
return nil
}
return s.TLS
}
type DashboardAuthType string
const (
DashboardAuthTypeNone DashboardAuthType = "none"
DashboardAuthTypeOAuth2 DashboardAuthType = "oauth2"
DashboardAuthTypeBasic DashboardAuthType = "basic"
)
type DashboardOAuth2Spec struct {
// TokenEndpoint - endpoint where Envoy will retrieve the OAuth2 access and identity token from
TokenEndpoint string `json:"tokenEndpoint"`
// AuthorizationEndpoint - endpoint where the user will be redirected to authenticate
AuthorizationEndpoint string `json:"authorizationEndpoint"`
// ClientID - client ID to authenticate with the OAuth2 provider
ClientID string `json:"clientId"`
// Scopes - scopes to request from the OAuth2 provider (e.g. "openid", "profile", ...) - optional
Scopes []string `json:"scopes,omitempty"`
// Resources - resources to request from the OAuth2 provider (e.g. "user", "email", ...) - optional
Resources []string `json:"resources,omitempty"`
// ClientSecretRef - reference to the secret that contains the client secret
ClientSecretRef *corev1.SecretKeySelector `json:"clientSecretRef"`
}
type DashboardBasicAuthSpec struct{}
type DashboardAuthSpec struct {
OAuth2 *DashboardOAuth2Spec `json:"oauth2,omitempty"`
Basic *DashboardBasicAuthSpec `json:"basic,omitempty"`
}
type DashboardEndpointSpec struct {
// Auth - configure authentication for the dashboard endpoint
Auth *DashboardAuthSpec `json:"auth,omitempty"`
// TLS - enable and configure TLS for the Dashboard endpoint
TLS *EndpointTlsSpec `json:"tls,omitempty"`
}
func (s *DashboardEndpointSpec) TLSSpec() *EndpointTlsSpec {
if s == nil {
return nil
}
return s.TLS
}
func (s *DashboardEndpointSpec) AuthType() DashboardAuthType {
if s == nil || s.Auth == nil {
return DashboardAuthTypeNone
}
if s.Auth.OAuth2 != nil {
return DashboardAuthTypeOAuth2
}
if s.Auth.Basic != nil {
return DashboardAuthTypeBasic
}
return DashboardAuthTypeNone
}
func (s *DashboardEndpointSpec) OAuth2() *DashboardOAuth2Spec {
if s == nil || s.Auth == nil {
return nil
}
return s.Auth.OAuth2
}
// APIGatewaySpec defines the desired state of APIGateway.
type APIGatewaySpec struct {

View file

@ -21,7 +21,7 @@ limitations under the License.
package v1alpha1
import (
v1 "k8s.io/api/core/v1"
"k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@ -101,7 +101,7 @@ func (in *APIGatewaySpec) DeepCopyInto(out *APIGatewaySpec) {
if in.DashboardEndpoint != nil {
in, out := &in.DashboardEndpoint, &out.DashboardEndpoint
*out = new(DashboardEndpointSpec)
**out = **in
(*in).DeepCopyInto(*out)
}
if in.ServiceSelector != nil {
in, out := &in.ServiceSelector, &out.ServiceSelector
@ -160,6 +160,11 @@ func (in *ApiEndpointSpec) DeepCopyInto(out *ApiEndpointSpec) {
*out = new(v1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
if in.TLS != nil {
in, out := &in.TLS, &out.TLS
*out = new(EndpointTlsSpec)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiEndpointSpec.
@ -490,6 +495,46 @@ func (in *Dashboard) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardAuthSpec) DeepCopyInto(out *DashboardAuthSpec) {
*out = *in
if in.OAuth2 != nil {
in, out := &in.OAuth2, &out.OAuth2
*out = new(DashboardOAuth2Spec)
(*in).DeepCopyInto(*out)
}
if in.Basic != nil {
in, out := &in.Basic, &out.Basic
*out = new(DashboardBasicAuthSpec)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardAuthSpec.
func (in *DashboardAuthSpec) DeepCopy() *DashboardAuthSpec {
if in == nil {
return nil
}
out := new(DashboardAuthSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardBasicAuthSpec) DeepCopyInto(out *DashboardBasicAuthSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardBasicAuthSpec.
func (in *DashboardBasicAuthSpec) DeepCopy() *DashboardBasicAuthSpec {
if in == nil {
return nil
}
out := new(DashboardBasicAuthSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardDbSpec) DeepCopyInto(out *DashboardDbSpec) {
*out = *in
@ -513,6 +558,16 @@ func (in *DashboardDbSpec) DeepCopy() *DashboardDbSpec {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardEndpointSpec) DeepCopyInto(out *DashboardEndpointSpec) {
*out = *in
if in.Auth != nil {
in, out := &in.Auth, &out.Auth
*out = new(DashboardAuthSpec)
(*in).DeepCopyInto(*out)
}
if in.TLS != nil {
in, out := &in.TLS, &out.TLS
*out = new(EndpointTlsSpec)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardEndpointSpec.
@ -557,6 +612,36 @@ func (in *DashboardList) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardOAuth2Spec) DeepCopyInto(out *DashboardOAuth2Spec) {
*out = *in
if in.Scopes != nil {
in, out := &in.Scopes, &out.Scopes
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Resources != nil {
in, out := &in.Resources, &out.Resources
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.ClientSecretRef != nil {
in, out := &in.ClientSecretRef, &out.ClientSecretRef
*out = new(v1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardOAuth2Spec.
func (in *DashboardOAuth2Spec) DeepCopy() *DashboardOAuth2Spec {
if in == nil {
return nil
}
out := new(DashboardOAuth2Spec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DashboardSpec) DeepCopyInto(out *DashboardSpec) {
*out = *in
@ -768,6 +853,26 @@ func (in *EmailAuthSmtpSpec) DeepCopy() *EmailAuthSmtpSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EndpointTlsSpec) DeepCopyInto(out *EndpointTlsSpec) {
*out = *in
if in.Cert != nil {
in, out := &in.Cert, &out.Cert
*out = new(TlsCertRef)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointTlsSpec.
func (in *EndpointTlsSpec) DeepCopy() *EndpointTlsSpec {
if in == nil {
return nil
}
out := new(EndpointTlsSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EnvoySpec) DeepCopyInto(out *EnvoySpec) {
*out = *in
@ -1261,6 +1366,21 @@ func (in *StudioSpec) DeepCopy() *StudioSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TlsCertRef) DeepCopyInto(out *TlsCertRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TlsCertRef.
func (in *TlsCertRef) DeepCopy() *TlsCertRef {
if in == nil {
return nil
}
out := new(TlsCertRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *UploadTempSpec) DeepCopyInto(out *UploadTempSpec) {
*out = *in

View file

@ -19,8 +19,10 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"strings"
"time"
clusterservice "github.com/envoyproxy/go-control-plane/envoy/service/cluster/v3"
@ -32,30 +34,52 @@ import (
secretservice "github.com/envoyproxy/go-control-plane/envoy/service/secret/v3"
cachev3 "github.com/envoyproxy/go-control-plane/pkg/cache/v3"
"github.com/envoyproxy/go-control-plane/pkg/server/v3"
"google.golang.org/grpc/credentials"
"github.com/go-logr/logr"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"google.golang.org/grpc"
grpchealth "google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/reflection"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/healthz"
mgr "sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"code.icb4dc0.de/prskr/supabase-operator/internal/certs"
"code.icb4dc0.de/prskr/supabase-operator/internal/controlplane"
"code.icb4dc0.de/prskr/supabase-operator/internal/health"
)
//nolint:lll // flag declaration with struct tags is as long as it is
type controlPlane struct {
ListenAddr string `name:"listen-address" default:":18000" help:"The address the control plane binds to."`
caCert tls.Certificate `kong:"-"`
ListenAddr string `name:"listen-address" default:":18000" help:"The address the control plane binds to."`
Tls struct {
CA struct {
Cert FileContent `env:"CERT" name:"server-cert" required:"" help:"The path to the server certificate file."`
Key FileContent `env:"KEY" name:"server-key" required:"" help:"The path to the server key file."`
} `embed:"" prefix:"ca." envprefix:"CA_"`
ServerSecretName string `name:"server-secret-name" help:"The name of the secret containing the server certificate and key." default:"control-plane-xds-tls"`
} `embed:"" prefix:"tls." envprefix:"TLS_"`
MetricsAddr string `name:"metrics-bind-address" default:"0" help:"The address the metrics endpoint binds to. Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service."`
EnableLeaderElection bool `name:"leader-elect" default:"false" help:"Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager."`
ProbeAddr string `name:"health-probe-bind-address" default:":8081" help:"The address the probe endpoint binds to."`
SecureMetrics bool `name:"metrics-secure" default:"true" help:"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead."`
EnableHTTP2 bool `name:"enable-http2" default:"false" help:"If set, HTTP/2 will be enabled for the metrics and webhook servers"`
ServiceName string `name:"service-name" env:"CONTROL_PLANE_SERVICE_NAME" default:"" required:"" help:"The name of the control plane service."`
Namespace string `name:"namespace" env:"CONTROL_PLANE_NAMESPACE" default:"" required:"" help:"Namespace where the controller is running, ideally set via downward API"`
}
func (cp controlPlane) Run(ctx context.Context) error {
func (cp *controlPlane) Run(ctx context.Context) error {
var tlsOpts []func(*tls.Config)
// if the enable-http2 flag is false (the default), http/2 should be disabled
@ -91,6 +115,11 @@ func (cp controlPlane) Run(ctx context.Context) error {
metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
}
bootstrapClient, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme})
if err != nil {
return fmt.Errorf("unable to create bootstrap client: %w", err)
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
@ -106,7 +135,12 @@ func (cp controlPlane) Run(ctx context.Context) error {
envoySnapshotCache := cachev3.NewSnapshotCache(false, cachev3.IDHash{}, nil)
envoySrv, err := cp.envoyServer(ctx, envoySnapshotCache)
serverCert, err := cp.ensureControlPlaneTlsCert(ctx, bootstrapClient)
if err != nil {
return fmt.Errorf("failed to ensure control plane TLS cert: %w", err)
}
envoySrv, err := cp.envoyServer(ctx, envoySnapshotCache, serverCert)
if err != nil {
return err
}
@ -123,6 +157,18 @@ func (cp controlPlane) Run(ctx context.Context) error {
return fmt.Errorf("unable to create controller Core DB: %w", err)
}
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
return fmt.Errorf("unable to set up health check: %w", err)
}
if err := mgr.AddHealthzCheck("server-cert", health.CertValidCheck(serverCert)); err != nil {
return fmt.Errorf("unable to set up health check: %w", err)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
return fmt.Errorf("unable to set up ready check: %w", err)
}
setupLog.Info("starting manager")
if err := mgr.Start(ctx); err != nil {
return fmt.Errorf("problem running manager: %w", err)
@ -131,9 +177,19 @@ func (cp controlPlane) Run(ctx context.Context) error {
return nil
}
func (cp controlPlane) envoyServer(
func (cp *controlPlane) AfterApply() (err error) {
cp.caCert, err = tls.X509KeyPair(cp.Tls.CA.Cert, cp.Tls.CA.Key)
if err != nil {
return fmt.Errorf("failed to parse server certificate: %w", err)
}
return nil
}
func (cp *controlPlane) envoyServer(
ctx context.Context,
cache cachev3.SnapshotCache,
serverCert tls.Certificate,
) (runnable mgr.Runnable, err error) {
const (
grpcKeepaliveTime = 30 * time.Second
@ -153,7 +209,17 @@ func (cp controlPlane) envoyServer(
// availability problems. Keepalive timeouts based on connection_keepalive parameter
// https://www.envoyproxy.io/docs/envoy/latest/configuration/overview/examples#dynamic
tlsCfg, err := cp.tlsConfig(serverCert)
if err != nil {
return nil, fmt.Errorf("failed to create TLS config: %w", err)
}
loggingOpts := []logging.Option{
logging.WithLogOnEvents(logging.StartCall, logging.FinishCall),
}
grpcOptions := append(make([]grpc.ServerOption, 0, 4),
grpc.Creds(credentials.NewTLS(tlsCfg)),
grpc.MaxConcurrentStreams(grpcMaxConcurrentStreams),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: grpcKeepaliveTime,
@ -163,6 +229,12 @@ func (cp controlPlane) envoyServer(
MinTime: grpcKeepaliveMinTime,
PermitWithoutStream: true,
}),
grpc.ChainUnaryInterceptor(
logging.UnaryServerInterceptor(InterceptorLogger(ctrl.Log), loggingOpts...),
),
grpc.ChainStreamInterceptor(
logging.StreamServerInterceptor(InterceptorLogger(ctrl.Log), loggingOpts...),
),
)
grpcServer := grpc.NewServer(grpcOptions...)
@ -195,3 +267,90 @@ func (cp controlPlane) envoyServer(
return grpcServer.Serve(lis)
}), nil
}
func (cp *controlPlane) ensureControlPlaneTlsCert(ctx context.Context, k8sClient client.Client) (tls.Certificate, error) {
var (
controlPlaneServerCert = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: cp.Tls.ServerSecretName,
Namespace: cp.Namespace,
},
}
serverCert tls.Certificate
)
_, err := controllerutil.CreateOrUpdate(ctx, k8sClient, controlPlaneServerCert, func() (err error) {
controlPlaneServerCert.Type = corev1.SecretTypeTLS
if controlPlaneServerCert.Data == nil {
controlPlaneServerCert.Data = make(map[string][]byte, 3)
}
var (
cert = controlPlaneServerCert.Data[corev1.TLSCertKey]
privateKey = controlPlaneServerCert.Data[corev1.TLSPrivateKeyKey]
)
var requireRenewal bool
if cert != nil && privateKey != nil {
if serverCert, err = tls.X509KeyPair(cert, privateKey); err != nil {
return fmt.Errorf("failed to parse server certificate: %w", err)
}
renewGracePeriod := time.Duration(float64(serverCert.Leaf.NotAfter.Sub(serverCert.Leaf.NotBefore)) * 0.1)
if serverCert.Leaf.NotAfter.Before(time.Now().Add(-renewGracePeriod)) {
requireRenewal = true
}
} else {
requireRenewal = true
}
if requireRenewal {
dnsNames := []string{
strings.Join([]string{cp.ServiceName, cp.Namespace, "svc"}, "."),
strings.Join([]string{cp.ServiceName, cp.Namespace, "svc", "cluster", "local"}, "."),
}
if certResult, err := certs.ServerCert("supabase-control-plane", dnsNames, cp.caCert); err != nil {
return fmt.Errorf("failed to generate server certificate: %w", err)
} else {
serverCert = certResult.ServerCert
controlPlaneServerCert.Data[corev1.TLSCertKey] = certResult.PublicKey
controlPlaneServerCert.Data[corev1.TLSPrivateKeyKey] = certResult.PrivateKey
}
}
return nil
})
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to create or update control plane server certificate: %w", err)
}
return serverCert, nil
}
func (cp *controlPlane) tlsConfig(serverCert tls.Certificate) (*tls.Config, error) {
tlsCfg := &tls.Config{
RootCAs: x509.NewCertPool(),
ClientCAs: x509.NewCertPool(),
ClientAuth: tls.RequireAndVerifyClientCert,
}
tlsCfg.Certificates = append(tlsCfg.Certificates, serverCert)
if !tlsCfg.RootCAs.AppendCertsFromPEM(cp.Tls.CA.Cert) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
if !tlsCfg.ClientCAs.AppendCertsFromPEM(cp.Tls.CA.Cert) {
return nil, fmt.Errorf("failed to parse client CA certificate")
}
return tlsCfg, nil
}
// InterceptorLogger adapts slog logger to interceptor logger.
// This code is simple enough to be copied and not imported.
func InterceptorLogger(l logr.Logger) logging.Logger {
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
l.Info(msg, fields...)
})
}

41
cmd/flags.go Normal file
View file

@ -0,0 +1,41 @@
/*
Copyright 2025 Peter Kurfer.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
"github.com/alecthomas/kong"
)
var _ kong.MapperValue = (*FileContent)(nil)
type FileContent []byte
func (f *FileContent) Decode(ctx *kong.DecodeContext) (err error) {
var filePath string
if err := ctx.Scan.PopValueInto("file-content", &filePath); err != nil {
return err
}
if *f, err = os.ReadFile(filePath); err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
return nil
}

View file

@ -40,6 +40,10 @@ type manager struct {
SecureMetrics bool `name:"metrics-secure" default:"true" help:"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead."`
EnableHTTP2 bool `name:"enable-http2" default:"false" help:"If set, HTTP/2 will be enabled for the metrics and webhook servers"`
Namespace string `name:"controller-namespace" env:"CONTROLLER_NAMESPACE" default:"" help:"Namespace where the controller is running, ideally set via downward API"`
Tls struct {
CACert FileContent `env:"CA_CERT" name:"ca-cert" required:"" help:"The path to the CA certificate file."`
CAKey FileContent `env:"CA_KEY" name:"ca-key" required:"" help:"The path to the CA key file."`
} `embed:"" prefix:"tls." envprefix:"TLS_"`
}
func (m manager) Run(ctx context.Context) error {
@ -68,6 +72,11 @@ func (m manager) Run(ctx context.Context) error {
TLSOpts: tlsOpts,
})
caCert, err := tls.X509KeyPair(m.Tls.CACert, m.Tls.CAKey)
if err != nil {
return fmt.Errorf("unable to load CA cert: %w", err)
}
// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.
// More info:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server
@ -145,6 +154,7 @@ func (m manager) Run(ctx context.Context) error {
if err = (&controller.APIGatewayReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
CACert: caCert,
}).SetupWithManager(ctx, mgr); err != nil {
return fmt.Errorf("unable to create controller APIGateway: %w", err)
}

View file

@ -0,0 +1,33 @@
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
labels:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: cp-selfsigned-issuer
namespace: system
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
labels:
app.kubernetes.io/name: certificate
app.kubernetes.io/instance: serving-cert
app.kubernetes.io/component: certificate
app.kubernetes.io/created-by: supabase-operator
app.kubernetes.io/part-of: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: cp-ca-cert
namespace: supabase-system
spec:
commonName: control-plane-ca
privateKey:
algorithm: ECDSA
issuerRef:
kind: Issuer
name: selfsigned-issuer
secretName: control-plane-ca-cert-tls
isCA: true

View file

@ -18,40 +18,26 @@ spec:
labels:
app.kubernetes.io/name: control-plane
spec:
# TODO(user): Uncomment the following code to configure the nodeAffinity expression
# according to the platforms which are supported by your solution.
# It is considered best practice to support multiple architectures. You can
# build your manager image using the makefile target docker-buildx.
# affinity:
# nodeAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# nodeSelectorTerms:
# - matchExpressions:
# - key: kubernetes.io/arch
# operator: In
# values:
# - amd64
# - arm64
# - ppc64le
# - s390x
# - key: kubernetes.io/os
# operator: In
# values:
# - linux
securityContext:
runAsNonRoot: true
# TODO(user): For common cases that do not require escalating privileges
# it is recommended to ensure that all your Pods/Containers are restrictive.
# More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
# Please uncomment the following code if your project does NOT have to work on old Kubernetes
# versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ).
# seccompProfile:
# type: RuntimeDefault
seccompProfile:
type: RuntimeDefault
containers:
- args:
- control-plane
image: supabase-operator:latest
name: control-plane
env:
- name: CONTROL_PLANE_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: CONTROL_PLANE_SERVICE_NAME
value: control-plane
- name: TLS_CA_CERT
value: /etc/supabase/control-plane/certs/tls.crt
- name: TLS_CA_KEY
value: /etc/supabase/control-plane/certs/tls.key
ports:
- containerPort: 18000
name: grpc
@ -62,17 +48,17 @@ spec:
drop:
- "ALL"
livenessProbe:
grpc:
port: 18000
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
grpc:
port: 18000
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
# TODO(user): Configure the resources accordingly based on the project requirements.
# More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources:
limits:
cpu: 150m
@ -80,5 +66,12 @@ spec:
requests:
cpu: 50m
memory: 64Mi
volumeMounts:
- name: tls-certs
mountPath: /etc/supabase/control-plane/certs
volumes:
- name: tls-certs
secret:
secretName: control-plane-ca-cert-tls
serviceAccountName: control-plane
terminationGracePeriodSeconds: 10

View file

@ -2,5 +2,9 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cert-ca.yaml
- control-plane.yaml
- service.yaml
configurations:
- kustomizeconfig.yaml

View file

@ -0,0 +1,8 @@
# This configuration is for teaching kustomize how to update name ref substitution
nameReference:
- kind: Issuer
group: cert-manager.io
fieldSpecs:
- kind: Certificate
group: cert-manager.io
path: spec/issuerRef/name

View file

@ -73,6 +73,36 @@ spec:
- key
type: object
x-kubernetes-map-type: atomic
tls:
description: TLS - enable and configure TLS for the API endpoint
properties:
cert:
properties:
caCertKey:
default: ca.crt
description: CaCertKey - key in the secret that contains
the CA certificate
type: string
secretName:
type: string
serverCertKey:
default: tls.crt
description: ServerCertKey - key in the secret that contains
the server certificate
type: string
serverKeyKey:
default: tls.key
description: ServerKeyKey - key in the secret that contains
the server private key
type: string
required:
- secretName
- serverCertKey
- serverKeyKey
type: object
required:
- cert
type: object
required:
- jwks
type: object
@ -85,6 +115,102 @@ spec:
description: |-
DashboardEndpoint - Configure the endpoint for the Supabase dashboard (studio)
this includes optional authentication (basic or Oauth2) for the dashboard
properties:
auth:
description: Auth - configure authentication for the dashboard
endpoint
properties:
basic:
type: object
oauth2:
properties:
authorizationEndpoint:
description: AuthorizationEndpoint - endpoint where the
user will be redirected to authenticate
type: string
clientId:
description: ClientID - client ID to authenticate with
the OAuth2 provider
type: string
clientSecretRef:
description: ClientSecretRef - reference to the secret
that contains the client secret
properties:
key:
description: The key of the secret to select from. Must
be a valid secret key.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the Secret or its key
must be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
resources:
description: Resources - resources to request from the
OAuth2 provider (e.g. "user", "email", ...) - optional
items:
type: string
type: array
scopes:
description: Scopes - scopes to request from the OAuth2
provider (e.g. "openid", "profile", ...) - optional
items:
type: string
type: array
tokenEndpoint:
description: TokenEndpoint - endpoint where Envoy will
retrieve the OAuth2 access and identity token from
type: string
required:
- authorizationEndpoint
- clientId
- clientSecretRef
- tokenEndpoint
type: object
type: object
tls:
description: TLS - enable and configure TLS for the Dashboard
endpoint
properties:
cert:
properties:
caCertKey:
default: ca.crt
description: CaCertKey - key in the secret that contains
the CA certificate
type: string
secretName:
type: string
serverCertKey:
default: tls.crt
description: ServerCertKey - key in the secret that contains
the server certificate
type: string
serverKeyKey:
default: tls.key
description: ServerKeyKey - key in the secret that contains
the server private key
type: string
required:
- secretName
- serverCertKey
- serverKeyKey
type: object
required:
- cert
type: object
type: object
envoy:
description: Envoy - configure the envoy instance and most importantly
@ -108,6 +234,11 @@ spec:
- host
- port
type: object
disableIPv6:
description: |-
DisableIPv6 - disable IPv6 for the Envoy instance
this will force Envoy to use IPv4 for upstream hosts (mostly for the OAuth2 token endpoint)
type: boolean
nodeName:
description: |-
NodeName - identifies the Envoy cluster within the current namespace

View file

@ -23,7 +23,6 @@ resources:
- ../certmanager
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
#- ../prometheus
# [METRICS] Expose the controller manager metrics service.
- metrics_service.yaml
# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy.
# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics.
@ -44,19 +43,31 @@ patches:
# crd/kustomization.yaml
- path: manager_webhook_patch.yaml
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
# Uncomment the following replacements to add the cert-manager CA injection annotations
replacements:
- source: # Uncomment the following block if you have any webhook
- source:
kind: Service
version: v1
name: control-plane
fieldPath: .metadata.name
targets:
- select:
kind: Deployment
group: apps
version: v1
name: control-plane
fieldPaths:
- .spec.template.spec.containers.*.env.[name=CONTROL_PLANE_SERVICE_NAME].value
- source:
kind: Service
version: v1
name: webhook-service
fieldPath: .metadata.name # Name of the service
fieldPath: .metadata.name
targets:
- select:
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert
fieldPaths:
- .spec.dnsNames.0
- .spec.dnsNames.1
@ -74,6 +85,7 @@ replacements:
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert
fieldPaths:
- .spec.dnsNames.0
- .spec.dnsNames.1

1
config/dev/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
studio-credentials-secret.yaml

View file

@ -1,3 +1,4 @@
---
apiVersion: supabase.k8s.icb4dc0.de/v1alpha1
kind: APIGateway
metadata:
@ -5,8 +6,44 @@ metadata:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: gateway-sample
namespace: supabase-demo
spec:
apiEndpoint:
jwks:
name: core-sample-jwt
key: jwks.json
dashboardEndpoint:
auth:
oauth2:
tokenEndpoint: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/oauth2/token"
authorizationEndpoint: "https://login.microsoftonline.com/f4e80111-1571-477a-b56d-c5fe517676b7/oauth2/authorize"
clientId: 3528016b-f6e3-49be-8fb3-f9a9a2ab6c3f
scopes:
- openid
- profile
- email
clientSecretRef:
name: studio-sample-oauth2
key: clientSecret
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
labels:
app.kubernetes.io/name: certificate
app.kubernetes.io/instance: dashboard-tls
app.kubernetes.io/component: certificate
app.kubernetes.io/created-by: supabase-operator
app.kubernetes.io/part-of: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: dashboard-tls
namespace: supabase-demo
spec:
dnsNames:
- gateway-sample-envoy.supabase-demo.svc
- gateway-sample-envoy.supabase-demo.svc.cluster.local
- localhost:3000
issuerRef:
kind: Issuer
name: selfsigned-issuer
secretName: dashboard-tls-cert

View file

@ -3,6 +3,7 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: pgsodium-config
namespace: supabase-demo
data:
pgsodium_getkey.sh: |
#!/bin/bash
@ -18,6 +19,7 @@ apiVersion: v1
kind: Secret
metadata:
name: pgsodium-key
namespace: supabase-demo
data:
# Generate a 32-byte key
# head -c 32 /dev/urandom | od -A n -t x1 | tr -d ' \n' | base64
@ -27,6 +29,7 @@ apiVersion: v1
kind: Secret
metadata:
name: supabase-admin-credentials
namespace: supabase-demo
labels:
cnpg.io/reload: "true"
type: kubernetes.io/basic-auth
@ -38,6 +41,7 @@ apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: cluster-example
namespace: supabase-demo
spec:
instances: 1
imageName: ghcr.io/supabase/postgres:15.8.1.021

View file

@ -2,6 +2,7 @@ apiVersion: v1
kind: Secret
metadata:
name: supabase-demo-credentials
namespace: supabase-demo
stringData:
url: postgresql://supabase_admin:1n1t-R00t!@cluster-example-rw.supabase-demo:5432/app
---
@ -12,6 +13,7 @@ metadata:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: core-sample
namespace: supabase-demo
spec:
# public URL of the Supabase instance (API)
# normally the Ingress/HTTPRoute endpoint

View file

@ -6,6 +6,7 @@ metadata:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: dashboard-sample
namespace: supabase-demo
spec:
db:
host: cluster-example-rw.supabase-demo.svc

View file

@ -4,8 +4,11 @@ kind: Kustomization
resources:
- https://github.com/cert-manager/cert-manager/releases/download/v1.16.3/cert-manager.yaml
- https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.25.0/cnpg-1.25.0.yaml
- namespace.yaml
- cnpg-cluster.yaml
- minio.yaml
- ../default
- studio-credentials-secret.yaml
- core.yaml
- apigateway.yaml
- dashboard.yaml

View file

@ -1,3 +0,0 @@
- op: replace
path: /spec/replicas
value: 1

View file

@ -1,4 +1,4 @@
# Deploys a new Namespace for the MinIO Pod
---
apiVersion: v1
kind: Namespace
metadata:

View file

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: supabase-demo

View file

@ -3,6 +3,7 @@ apiVersion: v1
kind: Secret
metadata:
name: storage-s3-credentials
namespace: supabase-demo
stringData:
accessKeyId: FPxTAFL7NaubjPgIGBo3
secretAccessKey: 7F437pPe84QcoocD3MWdAIVBU3oXonhVHxK645tm
@ -14,6 +15,7 @@ metadata:
app.kubernetes.io/name: supabase-operator
app.kubernetes.io/managed-by: kustomize
name: storage-sample
namespace: supabase-demo
spec:
api:
s3Backend:

View file

@ -0,0 +1,8 @@
---
apiVersion: v1
kind: Secret
metadata:
name: studio-sample-oauth2
namespace: supabase-demo
stringData:
clientSecret: "G9r8Q~o4LJRlTQwPpdCBaZLsWdhUxM_02Y_XBcEr"

View file

@ -20,26 +20,6 @@ spec:
labels:
control-plane: controller-manager
spec:
# TODO(user): Uncomment the following code to configure the nodeAffinity expression
# according to the platforms which are supported by your solution.
# It is considered best practice to support multiple architectures. You can
# build your manager image using the makefile target docker-buildx.
# affinity:
# nodeAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# nodeSelectorTerms:
# - matchExpressions:
# - key: kubernetes.io/arch
# operator: In
# values:
# - amd64
# - arm64
# - ppc64le
# - s390x
# - key: kubernetes.io/os
# operator: In
# values:
# - linux
securityContext:
runAsNonRoot: true
seccompProfile:
@ -56,6 +36,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: TLS_CA_CERT
value: /etc/supabase/operator/certs/tls.crt
- name: TLS_CA_KEY
value: /etc/supabase/operator/certs/tls.key
securityContext:
allowPrivilegeEscalation: false
capabilities:
@ -73,8 +57,6 @@ spec:
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
# TODO(user): Configure the resources accordingly based on the project requirements.
# More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources:
limits:
cpu: 150m
@ -82,5 +64,12 @@ spec:
requests:
cpu: 10m
memory: 64Mi
volumeMounts:
- name: tls-certs
mountPath: /etc/supabase/operator/certs
volumes:
- name: tls-certs
secret:
secretName: control-plane-ca-cert-tls
serviceAccountName: controller-manager
terminationGracePeriodSeconds: 10

View file

@ -4,6 +4,17 @@ kind: ClusterRole
metadata:
name: control-plane-role
rules:
- apiGroups:
- ""
resources:
- secrets
verbs:
- create
- update
- get
- list
- watch
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:
@ -12,6 +23,8 @@ rules:
- get
- list
- watch
- update
- apiGroups:
- supabase.k8s.icb4dc0.de
resources:

View file

@ -4,7 +4,6 @@ namespace: supabase-demo
resources:
- namespace.yaml
- cnpg-cluster.yaml
- supabase_v1alpha1_core.yaml
- supabase_v1alpha1_apigateway.yaml
- supabase_v1alpha1_dashboard.yaml

View file

@ -7,3 +7,6 @@ apiVersion: ctlptl.dev/v1alpha1
kind: Cluster
product: kind
registry: ctlptl-registry
kindV1Alpha4Cluster:
networking:
ipFamily: dual

23
go.mod
View file

@ -5,6 +5,8 @@ go 1.23.5
require (
github.com/alecthomas/kong v1.6.0
github.com/envoyproxy/go-control-plane v0.13.1
github.com/go-logr/logr v1.4.2
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0
github.com/jackc/pgx/v5 v5.7.1
github.com/lestrrat-go/jwx/v2 v2.1.3
github.com/magefile/mage v1.15.0
@ -12,8 +14,8 @@ require (
github.com/onsi/gomega v1.35.1
go.uber.org/zap v1.27.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.36.3
google.golang.org/grpc v1.70.0
google.golang.org/protobuf v1.36.4
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.32.1
k8s.io/apimachinery v0.32.1
@ -22,7 +24,7 @@ require (
)
require (
cel.dev/expr v0.18.0 // indirect
cel.dev/expr v0.19.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
github.com/beorn7/perks v1.0.1 // indirect
@ -30,7 +32,7 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b // indirect
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
@ -39,7 +41,6 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
@ -84,12 +85,12 @@ require (
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/sdk v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
@ -102,8 +103,8 @@ require (
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.26.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.32.1 // indirect

44
go.sum
View file

@ -1,5 +1,5 @@
cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo=
cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
cel.dev/expr v0.19.0 h1:lXuo+nDhpyJSpWxpPVi5cPUwzKb+dsdOiw6IreM5yt0=
cel.dev/expr v0.19.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.6.0 h1:mwOzbdMR7uv2vul9J0FU3GYxE7ls/iX1ieMg5WIM6gE=
@ -20,8 +20,8 @@ github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMr
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b h1:ga8SEFjZ60pxLcmhnThWgvH2wg8376yUJmPhEH4H3kw=
github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -82,6 +82,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 h1:kQ0NI7W1B3HwiN5gAYtY+XFItDPbLBwYRxAqbFTyDes=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0/go.mod h1:zrT2dxOAjNFPRGjTUe2Xmb4q4YdUwVvQFV6xiCSf+z0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@ -180,18 +182,20 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@ -247,14 +251,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a h1:OAiGFfOiA0v9MRYsSidp3ubZaBnteRUyn3xB2ZQ5G/E=
google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a/go.mod h1:jehYqy3+AhJU9ve55aNOaSml7wUXjF9x6z2LcCfpAhY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

60
internal/certs/format.go Normal file
View file

@ -0,0 +1,60 @@
/*
Copyright 2025 Peter Kurfer.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package certs
import (
"crypto/tls"
"encoding/pem"
"fmt"
)
const (
certificateBlockType = "CERTIFICATE"
privateKeyBlockType = "PRIVATE KEY"
)
func EncodePublicKeyToPEM(derBytes []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: certificateBlockType, Bytes: derBytes})
}
func EncodePrivateKeyToPEM(privateKeyBytes []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: privateKeyBlockType, Bytes: privateKeyBytes})
}
func newCertResult(derBytes, privateKeyBytes []byte) (CertResult, error) {
pemEncodedPublicKey := EncodePublicKeyToPEM(derBytes)
pemEncodedPrivateKey := EncodePrivateKeyToPEM(privateKeyBytes)
cert, err := tls.X509KeyPair(pemEncodedPublicKey, pemEncodedPrivateKey)
if err != nil {
return CertResult{}, fmt.Errorf("failed to create TLS cert based on x509 key pair: %w", err)
}
result := CertResult{
ServerCert: cert,
PublicKey: pemEncodedPublicKey,
PrivateKey: pemEncodedPrivateKey,
}
return result, nil
}
type CertResult struct {
ServerCert tls.Certificate
PublicKey []byte
PrivateKey []byte
}

122
internal/certs/generate.go Normal file
View file

@ -0,0 +1,122 @@
/*
Copyright 2025 Peter Kurfer.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package certs
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"time"
)
func ServerCert(
commonName string,
dnsNames []string,
ca tls.Certificate,
) (result CertResult, err error) {
serial, err := generateSerialNumber()
if err != nil {
return result, err
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return result, fmt.Errorf("failed to generate private key: %w", err)
}
certTemplate := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: dnsNames,
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().AddDate(0, 1, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment | x509.KeyUsageKeyEncipherment | x509.KeyUsageContentCommitment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
var caCrt *x509.Certificate
if caCrt, err = x509.ParseCertificate(ca.Certificate[0]); err != nil {
return result, fmt.Errorf("failed parse CA cert: %w", err)
}
var derBytes []byte
if derBytes, err = x509.CreateCertificate(rand.Reader, &certTemplate, caCrt, &privateKey.PublicKey, ca.PrivateKey); err != nil {
return result, fmt.Errorf("failed to create signed certificate: %w", err)
}
var privateKeyBytes []byte
if privateKeyBytes, err = x509.MarshalPKCS8PrivateKey(privateKey); err != nil {
return result, fmt.Errorf("failed to marshal private key: %w", err)
}
return newCertResult(derBytes, privateKeyBytes)
}
func ClientCert(
commonName string,
ca tls.Certificate,
) (result CertResult, err error) {
serial, err := generateSerialNumber()
if err != nil {
return result, err
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return result, fmt.Errorf("failed to generate private key: %w", err)
}
certTemplate := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().AddDate(0, 0, 7),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment | x509.KeyUsageKeyEncipherment | x509.KeyUsageContentCommitment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
var caCrt *x509.Certificate
if caCrt, err = x509.ParseCertificate(ca.Certificate[0]); err != nil {
return result, fmt.Errorf("failed parse CA cert: %w", err)
}
var derBytes []byte
if derBytes, err = x509.CreateCertificate(rand.Reader, &certTemplate, caCrt, &privateKey.PublicKey, ca.PrivateKey); err != nil {
return result, fmt.Errorf("failed to create signed certificate: %w", err)
}
var privateKeyBytes []byte
if privateKeyBytes, err = x509.MarshalPKCS8PrivateKey(privateKey); err != nil {
return result, fmt.Errorf("failed to marshal private key: %w", err)
}
return newCertResult(derBytes, privateKeyBytes)
}
func generateSerialNumber() (*big.Int, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
return rand.Int(rand.Reader, serialNumberLimit)
}

View file

@ -19,10 +19,13 @@ package controller
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"embed"
"encoding/hex"
"errors"
"fmt"
"strings"
"text/template"
"time"
@ -41,6 +44,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/certs"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
@ -64,6 +68,7 @@ func init() {
type APIGatewayReconciler struct {
client.Client
Scheme *runtime.Scheme
CACert tls.Certificate
}
// Reconcile is part of the main kubernetes reconciliation loop which aims to
@ -85,6 +90,14 @@ func (r *APIGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, err
}
if err := r.reconcileHmacSecret(ctx, &gateway); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to ensure HMAC token secret: %w", err)
}
if err := r.reconcileClientCertSecret(ctx, &gateway); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to ensure client certificate: %w", err)
}
if jwksHash, err = r.reconcileJwksSecret(ctx, &gateway); err != nil {
return ctrl.Result{}, err
}
@ -105,7 +118,8 @@ func (r *APIGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
// requeue after 15 minutes to watch for expiring certificates
return ctrl.Result{RequeueAfter: 15 * time.Minute}, nil
}
// SetupWithManager sets up the controller with the Manager.
@ -144,6 +158,8 @@ func (r *APIGatewayReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Ma
return ctrl.NewControllerManagedBy(mgr).
For(&supabasev1alpha1.APIGateway{}).
Named("apigateway").
// watch for the HMAC & client cert secrets
Owns(new(corev1.Secret)).
Owns(new(corev1.ConfigMap)).
Owns(new(appsv1.Deployment)).
Owns(new(corev1.Service)).
@ -219,6 +235,115 @@ func (r *APIGatewayReconciler) reconcileJwksSecret(
return hex.EncodeToString(HashBytes(jwksRaw)), nil
}
func (r *APIGatewayReconciler) reconcileHmacSecret(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
) error {
const hmacSecretLength = 32
serviceCfg := supabase.ServiceConfig.Envoy
hmacSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: serviceCfg.HmacSecretName(gateway),
Namespace: gateway.Namespace,
},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, hmacSecret, func() error {
if hmacSecret.Data == nil {
hmacSecret.Data = make(map[string][]byte)
}
if _, ok := hmacSecret.Data[serviceCfg.Defaults.HmacSecretKey]; !ok {
secret := make([]byte, hmacSecretLength)
if n, err := rand.Read(secret); err != nil {
return fmt.Errorf("failed to generate HMAC token secret: %w", err)
} else if n != hmacSecretLength {
return fmt.Errorf("failed to generate HMAC token secret: not enough bytes generated")
}
hmacSecret.Data[serviceCfg.Defaults.HmacSecretKey] = secret
}
if err := controllerutil.SetControllerReference(gateway, hmacSecret, r.Scheme); err != nil {
return fmt.Errorf("failed to set controller reference: %w", err)
}
return nil
})
return err
}
func (r *APIGatewayReconciler) reconcileClientCertSecret(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
) error {
var (
logger = log.FromContext(ctx)
clientCertSecret = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: supabase.ServiceConfig.Envoy.ControlPlaneClientCertSecretName(gateway),
Namespace: gateway.Namespace,
},
}
)
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, clientCertSecret, func() (err error) {
clientCertSecret.Type = corev1.SecretTypeTLS
if clientCertSecret.Data == nil {
clientCertSecret.Data = make(map[string][]byte, 3)
}
caCertBytes := certs.EncodePublicKeyToPEM(r.CACert.Certificate[0])
clientCertSecret.Data["ca.crt"] = caCertBytes
var (
cert = clientCertSecret.Data[corev1.TLSCertKey]
privateKey = clientCertSecret.Data[corev1.TLSPrivateKeyKey]
clientCert tls.Certificate
)
var requireRenewal bool
if cert != nil && privateKey != nil {
if clientCert, err = tls.X509KeyPair(cert, privateKey); err != nil {
return fmt.Errorf("failed to parse server certificate: %w", err)
}
renewGracePeriod := time.Duration(float64(clientCert.Leaf.NotAfter.Sub(clientCert.Leaf.NotBefore)) * 0.1)
if clientCert.Leaf.NotAfter.Before(time.Now().Add(-renewGracePeriod)) {
logger.Info("Envoy control-plane client certificate requires renewal",
"not_after", clientCert.Leaf.NotAfter,
"renew_grace_period", renewGracePeriod,
)
requireRenewal = true
}
} else {
logger.Info("Client cert is not set creating a new one")
requireRenewal = true
}
if requireRenewal {
if certResult, err := certs.ClientCert(strings.Join([]string{gateway.Name, gateway.Namespace}, ":"), r.CACert); err != nil {
return fmt.Errorf("failed to generate server certificate: %w", err)
} else {
clientCert = certResult.ServerCert
clientCertSecret.Data[corev1.TLSCertKey] = certResult.PublicKey
clientCertSecret.Data[corev1.TLSPrivateKeyKey] = certResult.PrivateKey
}
}
if err := controllerutil.SetControllerReference(gateway, clientCertSecret, r.Scheme); err != nil {
return fmt.Errorf("failed to set controller reference: %w", err)
}
return nil
})
return err
}
func (r *APIGatewayReconciler) reconcileEnvoyConfig(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
@ -291,6 +416,12 @@ func (r *APIGatewayReconciler) reconileEnvoyDeployment(
gateway *supabasev1alpha1.APIGateway,
configHash, jwksHash string,
) error {
const (
configVolumeName = "config"
controlPlaneTlsVolumeName = "cp-tls"
dashboardTlsVolumeName = "dashboard-tls"
apiTlsVolumeName = "api-tls"
)
envoyDeployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: supabase.ServiceConfig.Envoy.ObjectName(gateway),
@ -317,6 +448,131 @@ func (r *APIGatewayReconciler) reconileEnvoyDeployment(
envoyDeployment.Spec.Replicas = envoySpec.WorkloadTemplate.ReplicaCount()
configVolumeProjectionSources := []corev1.VolumeProjection{
{
ConfigMap: &corev1.ConfigMapProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: supabase.ServiceConfig.Envoy.ObjectName(gateway),
},
Items: []corev1.KeyToPath{
{
Key: "config.yaml",
Path: "config.yaml",
},
},
},
},
{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: gateway.Spec.ApiEndpoint.JWKSSelector.Name,
},
Items: []corev1.KeyToPath{{
Key: gateway.Spec.ApiEndpoint.JWKSSelector.Key,
Path: "jwks.json",
}},
},
},
{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: serviceCfg.ControlPlaneClientCertSecretName(gateway),
},
Items: []corev1.KeyToPath{
{
Key: "ca.crt",
Path: "certs/cp/ca.crt",
},
{
Key: "tls.crt",
Path: "certs/cp/tls.crt",
},
{
Key: "tls.key",
Path: "certs/cp/tls.key",
},
},
},
},
}
if oauth2Spec := gateway.Spec.DashboardEndpoint.OAuth2(); oauth2Spec != nil {
configVolumeProjectionSources = append(configVolumeProjectionSources, corev1.VolumeProjection{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: oauth2Spec.ClientSecretRef.Name,
},
Items: []corev1.KeyToPath{{
Key: oauth2Spec.ClientSecretRef.Key,
Path: serviceCfg.Defaults.OAuth2ClientSecretKey,
}},
},
})
}
volumeMounts := []corev1.VolumeMount{
{
Name: configVolumeName,
ReadOnly: true,
MountPath: "/etc/envoy",
},
}
volumes := []corev1.Volume{
{
Name: configVolumeName,
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
Sources: configVolumeProjectionSources,
},
},
},
{
Name: controlPlaneTlsVolumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: serviceCfg.ControlPlaneClientCertSecretName(gateway),
},
},
},
}
if tlsSpec := gateway.Spec.ApiEndpoint.TLSSpec(); tlsSpec != nil {
volumes = append(volumes, corev1.Volume{
Name: apiTlsVolumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: tlsSpec.Cert.SecretName,
},
},
})
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: dashboardTlsVolumeName,
ReadOnly: true,
MountPath: "/etc/envoy/certs/api",
SubPath: "certs/api",
})
}
if tlsSpec := gateway.Spec.DashboardEndpoint.TLSSpec(); tlsSpec != nil {
volumes = append(volumes, corev1.Volume{
Name: dashboardTlsVolumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: tlsSpec.Cert.SecretName,
},
},
})
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: dashboardTlsVolumeName,
ReadOnly: true,
MountPath: "/etc/envoy/certs/dashboard",
SubPath: "certs/dashboard",
})
}
envoyDeployment.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
@ -333,16 +589,21 @@ func (r *APIGatewayReconciler) reconileEnvoyDeployment(
Name: "envoy-proxy",
Image: envoySpec.WorkloadTemplate.Image(supabase.Images.Envoy.String()),
ImagePullPolicy: envoySpec.WorkloadTemplate.ImagePullPolicy(),
Args: []string{"-c /etc/envoy/config.yaml"},
Args: []string{"-c /etc/envoy/config.yaml"}, // , "--component-log-level", "upstream:debug,connection:debug"
Ports: []corev1.ContainerPort{
{
Name: "http",
ContainerPort: 8000,
Name: serviceCfg.Defaults.StudioPortName,
ContainerPort: serviceCfg.Defaults.StudioPort,
Protocol: corev1.ProtocolTCP,
},
{
Name: serviceCfg.Defaults.ApiPortName,
ContainerPort: serviceCfg.Defaults.ApiPort,
Protocol: corev1.ProtocolTCP,
},
{
Name: "admin",
ContainerPort: 19000,
ContainerPort: serviceCfg.Defaults.AdminPort,
Protocol: corev1.ProtocolTCP,
},
},
@ -371,49 +632,11 @@ func (r *APIGatewayReconciler) reconileEnvoyDeployment(
},
SecurityContext: envoySpec.WorkloadTemplate.ContainerSecurityContext(serviceCfg.Defaults.UID, serviceCfg.Defaults.GID),
Resources: envoySpec.WorkloadTemplate.Resources(),
VolumeMounts: envoySpec.WorkloadTemplate.AdditionalVolumeMounts(
corev1.VolumeMount{
Name: "config",
ReadOnly: true,
MountPath: "/etc/envoy",
},
),
VolumeMounts: envoySpec.WorkloadTemplate.AdditionalVolumeMounts(volumeMounts...),
},
},
SecurityContext: envoySpec.WorkloadTemplate.PodSecurityContext(),
Volumes: []corev1.Volume{
{
Name: "config",
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
Sources: []corev1.VolumeProjection{
{
ConfigMap: &corev1.ConfigMapProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: supabase.ServiceConfig.Envoy.ObjectName(gateway),
},
Items: []corev1.KeyToPath{{
Key: "config.yaml",
Path: "config.yaml",
}},
},
},
{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: gateway.Spec.ApiEndpoint.JWKSSelector.Name,
},
Items: []corev1.KeyToPath{{
Key: gateway.Spec.ApiEndpoint.JWKSSelector.Key,
Path: "jwks.json",
}},
},
},
},
},
},
},
},
Volumes: volumes,
},
}
@ -431,12 +654,15 @@ func (r *APIGatewayReconciler) reconcileEnvoyService(
ctx context.Context,
gateway *supabasev1alpha1.APIGateway,
) error {
envoyService := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: supabase.ServiceConfig.Envoy.ObjectName(gateway),
Namespace: gateway.Namespace,
},
}
var (
serviceCfg = supabase.ServiceConfig.Envoy
envoyService = &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: supabase.ServiceConfig.Envoy.ObjectName(gateway),
Namespace: gateway.Namespace,
},
}
)
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, envoyService, func() error {
envoyService.Labels = MergeLabels(objectLabels(gateway, "envoy", "api-gateway", supabase.Images.Envoy.Tag), gateway.Labels)
@ -445,11 +671,18 @@ func (r *APIGatewayReconciler) reconcileEnvoyService(
Selector: selectorLabels(gateway, "envoy"),
Ports: []corev1.ServicePort{
{
Name: "rest",
Name: serviceCfg.Defaults.StudioPortName,
Protocol: corev1.ProtocolTCP,
AppProtocol: ptrOf("http"),
Port: 8000,
TargetPort: intstr.IntOrString{IntVal: 8000},
Port: serviceCfg.Defaults.StudioPort,
TargetPort: intstr.IntOrString{IntVal: serviceCfg.Defaults.StudioPort},
},
{
Name: serviceCfg.Defaults.ApiPortName,
Protocol: corev1.ProtocolTCP,
AppProtocol: ptrOf("http"),
Port: serviceCfg.Defaults.ApiPort,
TargetPort: intstr.IntOrString{IntVal: serviceCfg.Defaults.ApiPort},
},
},
}

View file

@ -192,7 +192,7 @@ func (r *CoreAuthReconciler) reconcileAuthDeployment(
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Path: svcCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: svcCfg.Defaults.APIPort},
},
},
@ -203,7 +203,7 @@ func (r *CoreAuthReconciler) reconcileAuthDeployment(
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Path: svcCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: svcCfg.Defaults.APIPort},
},
},

View file

@ -195,7 +195,7 @@ func (r *CorePostgrestReconiler) reconilePostgrestDeployment(
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/ready",
Path: serviceCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: serviceCfg.Defaults.AdminPort},
},
},

View file

@ -147,7 +147,7 @@ func (r *DashboardPGMetaReconciler) reconcilePGMetaDeployment(
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Path: serviceCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: serviceCfg.Defaults.APIPort},
},
},
@ -158,7 +158,7 @@ func (r *DashboardPGMetaReconciler) reconcilePGMetaDeployment(
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Path: serviceCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: serviceCfg.Defaults.APIPort},
},
},

View file

@ -161,7 +161,7 @@ func (r *DashboardStudioReconciler) reconcileStudioDeployment(
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/api/profile",
Path: serviceCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: serviceCfg.Defaults.APIPort},
},
},
@ -172,7 +172,7 @@ func (r *DashboardStudioReconciler) reconcileStudioDeployment(
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/api/profile",
Path: serviceCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: serviceCfg.Defaults.APIPort},
},
},

View file

@ -225,7 +225,7 @@ func (r *StorageApiReconciler) reconcileStorageApiDeployment(
SuccessThreshold: 2,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/status",
Path: serviceCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: serviceCfg.Defaults.ApiPort},
},
},
@ -236,7 +236,7 @@ func (r *StorageApiReconciler) reconcileStorageApiDeployment(
TimeoutSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/status",
Path: serviceCfg.LivenessProbePath,
Port: intstr.IntOrString{IntVal: serviceCfg.Defaults.ApiPort},
},
},

View file

@ -184,7 +184,7 @@ func (r *StorageImgProxyReconciler) reconcileImgProxyService(
var (
serviceCfg = supabase.ServiceConfig.ImgProxy
imgProxyService = &corev1.Service{
ObjectMeta: supabase.ServiceConfig.Storage.ObjectMeta(storage),
ObjectMeta: supabase.ServiceConfig.ImgProxy.ObjectMeta(storage),
}
)

View file

@ -15,13 +15,9 @@ dynamic_resources:
static_resources:
clusters:
- type: STRICT_DNS
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
name: {{ .ControlPlane.Name }}
- name: {{ .ControlPlane.Name }}
type: STRICT_DNS
connect_timeout: 1s
load_assignment:
cluster_name: {{ .ControlPlane.Name }}
endpoints:
@ -31,9 +27,38 @@ static_resources:
socket_address:
address: {{ .ControlPlane.Host }}
port_value: {{ .ControlPlane.Port }}
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
transport_socket:
name: "envoy.transport_sockets.tls"
typed_config:
"@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext"
sni: {{ .ControlPlane.Host }}
common_tls_context:
tls_certificates:
- certificate_chain:
filename: /etc/envoy/certs/cp/tls.crt
private_key:
filename: /etc/envoy/certs/cp/tls.key
validation_context:
trusted_ca:
filename: /etc/envoy/certs/cp/ca.crt
admin:
address:
socket_address:
address: 0.0.0.0
port_value: 19000
application_log_config:
log_format:
json_format:
type: "app"
name: "%n"
timestamp: "%Y-%m-%dT%T.%F"
level: "%l"
message: "%j"

View file

@ -19,14 +19,14 @@ package controlplane
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"strconv"
"strings"
"sync/atomic"
"time"
cachev3 "github.com/envoyproxy/go-control-plane/pkg/cache/v3"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@ -46,6 +46,7 @@ import (
// APIGatewayReconciler reconciles a APIGateway object
type APIGatewayReconciler struct {
initialReconciliation atomic.Bool
client.Client
Scheme *runtime.Scheme
Cache cachev3.SnapshotCache
@ -63,7 +64,7 @@ func (r *APIGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request)
endpointSliceList discoveryv1.EndpointSliceList
)
logger.Info("Reconciling APIGateway")
logger.Info("Reconciling Envoy control-plane config")
if err := r.Get(ctx, req.NamespacedName, &gateway); client.IgnoreNotFound(err) != nil {
logger.Error(err, "unable to fetch Gateway")
@ -79,25 +80,31 @@ func (r *APIGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, err
}
services := EnvoyServices{ServiceLabelKey: gateway.Spec.ComponentTypeLabel}
services := EnvoyServices{
ServiceLabelKey: gateway.Spec.ComponentTypeLabel,
Gateway: &gateway,
Client: r.Client,
}
services.UpsertEndpointSlices(endpointSliceList.Items...)
rawServices, err := json.Marshal(services)
instance := fmt.Sprintf("%s:%s", gateway.Spec.Envoy.NodeName, gateway.Namespace)
logger.Info("Computing Envoy snapshot for current service targets", "version", gateway.Status.Envoy.ConfigVersion)
snapshot, snapshotHash, err := services.snapshot(ctx, instance, gateway.Status.Envoy.ConfigVersion)
if err != nil {
return ctrl.Result{}, fmt.Errorf("failed to prepare config hash: %w", err)
return ctrl.Result{}, fmt.Errorf("failed to prepare snapshot: %w", err)
}
serviceHash := fnv.New64a().Sum(rawServices)
if bytes.Equal(serviceHash, gateway.Status.Envoy.ResourceHash) {
logger.Info("Resource hash did not change - skipping reconciliation")
if !r.initialReconciliation.CompareAndSwap(false, true) && bytes.Equal(gateway.Status.Envoy.ResourceHash, snapshotHash) {
logger.Info("No changes detected, skipping update")
return ctrl.Result{}, nil
}
logger.Info("Updating service targets")
_, err = controllerutil.CreateOrPatch(ctx, r.Client, &gateway, func() error {
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, &gateway, func() error {
gateway.Status.ServiceTargets = services.Targets()
gateway.Status.Envoy.ConfigVersion = strconv.FormatInt(time.Now().UTC().UnixMilli(), 10)
gateway.Status.Envoy.ResourceHash = serviceHash
gateway.Status.Envoy.ResourceHash = snapshotHash
return nil
})
@ -105,14 +112,6 @@ func (r *APIGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, err
}
instance := fmt.Sprintf("%s:%s", gateway.Spec.Envoy.NodeName, gateway.Namespace)
logger.Info("Computing Envoy snapshot for current service targets", "version", gateway.Status.Envoy.ConfigVersion)
snapshot, err := services.snapshot(ctx, instance, gateway.Status.Envoy.ConfigVersion)
if err != nil {
return ctrl.Result{}, fmt.Errorf("failed to prepare snapshot: %w", err)
}
logger.Info("Propagating Envoy snapshot", "version", gateway.Status.Envoy.ConfigVersion)
if err := r.Cache.SetSnapshot(ctx, instance, snapshot); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to propagate snapshot: %w", err)
@ -133,7 +132,8 @@ func (r *APIGatewayReconciler) SetupWithManager(mgr ctrl.Manager) error {
}
return ctrl.NewControllerManagedBy(mgr).
For(new(supabasev1alpha1.APIGateway)).
For(new(supabasev1alpha1.APIGateway), builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Owns(new(corev1.Secret), builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Watches(
new(discoveryv1.EndpointSlice),
r.endpointSliceEventHandler(),
@ -156,6 +156,7 @@ func (r *APIGatewayReconciler) endpointSliceEventHandler() handler.TypedEventHan
return nil
}
logger.Info("Triggering APIGateway reconciliation", "obj_name", obj.GetName(), "obj_namespace", obj.GetNamespace())
if err := r.Client.List(ctx, &apiGatewayList, client.InNamespace(endpointSlice.Namespace)); err != nil {
logger.Error(err, "failed to list APIGateways to determine reconcile targets")
return nil

View file

@ -17,9 +17,7 @@ limitations under the License.
package controlplane
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
@ -30,27 +28,10 @@ import (
discoveryv1 "k8s.io/api/discovery/v1"
)
var _ json.Marshaler = (*ServiceCluster)(nil)
type ServiceCluster struct {
ServiceEndpoints map[string]Endpoints
}
// MarshalJSON implements json.Marshaler.
func (c *ServiceCluster) MarshalJSON() ([]byte, error) {
tmp := struct {
Endpoints []string `json:"endpoints"`
}{}
for _, endpoints := range c.ServiceEndpoints {
tmp.Endpoints = append(tmp.Endpoints, endpoints.Targets...)
}
slices.Sort(tmp.Endpoints)
return json.Marshal(tmp)
}
func (c *ServiceCluster) AddOrUpdateEndpoints(eps discoveryv1.EndpointSlice) {
if c.ServiceEndpoints == nil {
c.ServiceEndpoints = make(map[string]Endpoints)

View file

@ -22,4 +22,6 @@ const (
FilterNameCORS = "envoy.filters.http.cors"
FilterNameHttpRouter = "envoy.filters.http.router"
FilterNameHttpConnectionManager = "envoy.filters.network.http_connection_manager"
FilterNameBasicAuth = "envoy.filters.http.basic_auth"
FilterNameOAuth2 = "envoy.filters.http.oauth2"
)

View file

@ -0,0 +1,62 @@
/*
Copyright 2025 Peter Kurfer.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package controlplane
import (
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
streamv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/stream/v3"
"google.golang.org/protobuf/types/known/structpb"
)
func AccessLog(name string) *accesslogv3.AccessLog {
return &accesslogv3.AccessLog{
Name: name,
Filter: &accesslogv3.AccessLogFilter{
FilterSpecifier: &accesslogv3.AccessLogFilter_NotHealthCheckFilter{
NotHealthCheckFilter: new(accesslogv3.NotHealthCheckFilter),
},
},
ConfigType: &accesslogv3.AccessLog_TypedConfig{
TypedConfig: MustAny(&streamv3.StderrAccessLog{
AccessLogFormat: &streamv3.StderrAccessLog_LogFormat{
LogFormat: &corev3.SubstitutionFormatString{
Format: &corev3.SubstitutionFormatString_JsonFormat{
JsonFormat: &structpb.Struct{
Fields: map[string]*structpb.Value{
"type": {Kind: &structpb.Value_StringValue{StringValue: "access_log"}},
"timestamp": {Kind: &structpb.Value_StringValue{StringValue: "%START_TIME%"}},
"protocol": {Kind: &structpb.Value_StringValue{StringValue: "%PROTOCOL%"}},
"http_method": {Kind: &structpb.Value_StringValue{StringValue: "%REQ(:METHOD)%"}},
"original_path": {Kind: &structpb.Value_StringValue{StringValue: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"}},
"forwarded_for": {Kind: &structpb.Value_StringValue{StringValue: "%REQ(X-FORWARDED-FOR)%"}},
"user_agent": {Kind: &structpb.Value_StringValue{StringValue: "%REQ(USER-AGENT)%"}},
"request_id": {Kind: &structpb.Value_StringValue{StringValue: "%REQ(X-REQUEST-ID)%"}},
"authority": {Kind: &structpb.Value_StringValue{StringValue: "%REQ(:AUTHORITY)%"}},
"upstream_host": {Kind: &structpb.Value_StringValue{StringValue: "%UPSTREAM_HOST%"}},
"response_code": {Kind: &structpb.Value_StringValue{StringValue: "%RESPONSE_CODE%"}},
"response_code_details": {Kind: &structpb.Value_StringValue{StringValue: "%RESPONSE_CODE_DETAILS%"}},
"trace_id": {Kind: &structpb.Value_StringValue{StringValue: "%TRACE_ID%"}},
},
},
},
},
},
}),
},
}
}

View file

@ -18,30 +18,56 @@ package controlplane
import (
"context"
"crypto/sha256"
"fmt"
"net/url"
"slices"
"strconv"
"time"
accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
endpointv3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
route "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
router "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
oauth2v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/oauth2/v3"
routerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3"
hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3"
matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
"github.com/envoyproxy/go-control-plane/pkg/cache/types"
"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
"github.com/envoyproxy/go-control-plane/pkg/resource/v3"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/durationpb"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
const (
studioRouteName = "supabase-studio"
dashboardOAuth2ClusterName = "dashboard-oauth"
apiRouteName = "supabase-api"
apilistenerName = "supabase-api"
)
type EnvoyServices struct {
ServiceLabelKey string `json:"-"`
Postgrest *PostgrestCluster `json:"postgrest,omitempty"`
GoTrue *GoTrueCluster `json:"auth,omitempty"`
StorageApi *StorageApiCluster `json:"storageApi,omitempty"`
PGMeta *PGMetaCluster `json:"pgmeta,omitempty"`
Studio *StudioCluster `json:"studio,omitempty"`
client.Client
ServiceLabelKey string
Gateway *supabasev1alpha1.APIGateway
Postgrest *PostgrestCluster
GoTrue *GoTrueCluster
StorageApi *StorageApiCluster
PGMeta *PGMetaCluster
Studio *StudioCluster
}
func (s *EnvoyServices) UpsertEndpointSlices(endpointSlices ...discoveryv1.EndpointSlice) {
@ -67,6 +93,12 @@ func (s *EnvoyServices) UpsertEndpointSlices(endpointSlices ...discoveryv1.Endpo
s.PGMeta = new(PGMetaCluster)
}
s.PGMeta.AddOrUpdateEndpoints(eps)
case supabase.ServiceConfig.Studio.Name:
if s.Studio == nil {
s.Studio = new(StudioCluster)
}
s.Studio.AddOrUpdateEndpoints(eps)
}
}
}
@ -97,19 +129,173 @@ func (s EnvoyServices) Targets() map[string][]string {
return targets
}
func (s *EnvoyServices) snapshot(ctx context.Context, instance, version string) (*cache.Snapshot, error) {
const (
apiRouteName = "supabase"
studioRouteName = "supabas-studio"
vHostName = "supabase"
listenerName = "supabase"
)
func (s *EnvoyServices) snapshot(ctx context.Context, instance, version string) (snapshot *cache.Snapshot, snapshotHash []byte, err error) {
logger := log.FromContext(ctx)
apiConnectionManager := &hcm.HttpConnectionManager{
listeners := []*listenerv3.Listener{{
Name: apilistenerName,
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Protocol: corev3.SocketAddress_TCP,
Address: "0.0.0.0",
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: 8000,
},
},
},
},
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
{
Name: FilterNameHttpConnectionManager,
ConfigType: &listenerv3.Filter_TypedConfig{
TypedConfig: MustAny(s.apiConnectionManager()),
},
},
},
},
},
}}
if studioListener := s.studioListener(); studioListener != nil {
logger.Info("Adding studio listener")
listeners = append(listeners, studioListener)
}
routes := []types.Resource{s.apiRouteConfiguration(instance)}
if studioRouteCfg := s.studioRoute(instance); studioRouteCfg != nil {
logger.Info("Adding studio route")
routes = append(routes, studioRouteCfg)
}
clusters := castResources(
slices.Concat(
s.Postgrest.Cluster(instance),
s.GoTrue.Cluster(instance),
s.StorageApi.Cluster(instance),
s.PGMeta.Cluster(instance),
s.Studio.Cluster(instance),
)...)
if oauth2Spec := s.Gateway.Spec.DashboardEndpoint.OAuth2(); oauth2Spec != nil {
if oauth2TokenEndpointCluster, err := s.oauth2TokenEndpointCluster(); err != nil {
return nil, nil, err
} else {
logger.Info("Adding OAuth2 token endpoint cluster")
clusters = append(clusters, oauth2TokenEndpointCluster)
}
}
sdsSecrets, err := s.secrets(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to collect dynamic secrets: %w", err)
}
rawSnapshot := map[resource.Type][]types.Resource{
resource.ClusterType: clusters,
resource.RouteType: routes,
resource.ListenerType: castResources(listeners...),
resource.SecretType: sdsSecrets,
}
hash := sha256.New()
for _, resType := range []resource.Type{resource.ClusterType, resource.RouteType, resource.ListenerType, resource.SecretType} {
for _, resource := range rawSnapshot[resType] {
if raw, err := proto.Marshal(resource); err != nil {
return nil, nil, err
} else {
_, _ = hash.Write(raw)
}
}
}
snapshot, err = cache.NewSnapshot(
version,
rawSnapshot,
)
if err != nil {
return nil, nil, err
}
if err := snapshot.Consistent(); err != nil {
return nil, nil, err
}
return snapshot, hash.Sum(nil), nil
}
func (s *EnvoyServices) secrets(ctx context.Context) ([]types.Resource, error) {
var (
serviceConfig = supabase.ServiceConfig.Envoy
resources = make([]types.Resource, 0, 2)
)
hmacSecret, err := s.k8sSecretKeyToSecret(
ctx,
serviceConfig.HmacSecretName(s.Gateway),
serviceConfig.Defaults.HmacSecretKey,
serviceConfig.Defaults.HmacSecretKey,
)
if err == nil {
resources = append(resources, hmacSecret)
} else if client.IgnoreNotFound(err) != nil {
return nil, err
}
if oauth2Spec := s.Gateway.Spec.DashboardEndpoint.OAuth2(); oauth2Spec != nil {
oauth2ClientSecret, err := s.k8sSecretKeyToSecret(
ctx,
oauth2Spec.ClientSecretRef.Name,
oauth2Spec.ClientSecretRef.Key,
serviceConfig.Defaults.OAuth2ClientSecretKey,
)
if err == nil {
resources = append(resources, oauth2ClientSecret)
} else if client.IgnoreNotFound(err) != nil {
return nil, err
}
}
return resources, nil
}
func (s *EnvoyServices) k8sSecretKeyToSecret(ctx context.Context, secretName, secretKey, envoySecretName string) (*tlsv3.Secret, error) {
k8sSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: s.Gateway.Namespace,
},
}
if err := s.Get(ctx, client.ObjectKeyFromObject(k8sSecret), k8sSecret); err != nil {
return nil, err
}
return &tlsv3.Secret{
Name: envoySecretName,
Type: &tlsv3.Secret_GenericSecret{
GenericSecret: &tlsv3.GenericSecret{
Secret: &corev3.DataSource{
Specifier: &corev3.DataSource_InlineBytes{
InlineBytes: k8sSecret.Data[secretKey],
},
},
},
},
}, nil
}
func (s *EnvoyServices) apiConnectionManager() *hcm.HttpConnectionManager {
return &hcm.HttpConnectionManager{
CodecType: hcm.HttpConnectionManager_AUTO,
StatPrefix: "http",
StatPrefix: "supabase_rest",
AccessLog: []*accesslogv3.AccessLog{AccessLog("supabase-rest-access-log")},
RouteSpecifier: &hcm.HttpConnectionManager_Rds{
Rds: &hcm.Rds{
ConfigSource: &corev3.ConfigSource{
@ -141,14 +327,109 @@ func (s *EnvoyServices) snapshot(ctx context.Context, instance, version string)
},
{
Name: FilterNameHttpRouter,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(router.Router))},
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(routerv3.Router))},
},
},
}
}
func (s *EnvoyServices) apiRouteConfiguration(instance string) *routev3.RouteConfiguration {
return &routev3.RouteConfiguration{
Name: apiRouteName,
VirtualHosts: []*routev3.VirtualHost{{
Name: "supabase",
Domains: []string{"*"},
TypedPerFilterConfig: map[string]*anypb.Any{
FilterNameJwtAuthn: MustAny(JWTPerRouteConfig()),
FilterNameRBAC: MustAny(RBACPerRoute(RBACRequireAuthConfig())),
},
Routes: slices.Concat(
s.Postgrest.Routes(instance),
s.GoTrue.Routes(instance),
s.StorageApi.Routes(instance),
s.PGMeta.Routes(instance),
),
}},
TypedPerFilterConfig: map[string]*anypb.Any{
FilterNameCORS: MustAny(CorsPolicy()),
},
}
}
func (s *EnvoyServices) studioListener() *listenerv3.Listener {
if s.Studio == nil {
return nil
}
var (
httpFilters []*hcm.HttpFilter
serviceCfg = supabase.ServiceConfig.Envoy
)
if oauth2Spec := s.Gateway.Spec.DashboardEndpoint.OAuth2(); oauth2Spec != nil {
httpFilters = append(httpFilters, &hcm.HttpFilter{
Name: FilterNameOAuth2,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(&oauth2v3.OAuth2{
Config: &oauth2v3.OAuth2Config{
TokenEndpoint: &corev3.HttpUri{
HttpUpstreamType: &corev3.HttpUri_Cluster{
Cluster: dashboardOAuth2ClusterName,
},
Uri: s.Gateway.Spec.DashboardEndpoint.Auth.OAuth2.TokenEndpoint,
Timeout: durationpb.New(3 * time.Second),
},
AuthorizationEndpoint: s.Gateway.Spec.DashboardEndpoint.Auth.OAuth2.AuthorizationEndpoint,
RedirectUri: "%REQ(x-forwarded-proto)%://%REQ(:authority)%/callback",
RedirectPathMatcher: &matcherv3.PathMatcher{
Rule: &matcherv3.PathMatcher_Path{
Path: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "/callback",
},
},
},
},
SignoutPath: &matcherv3.PathMatcher{
Rule: &matcherv3.PathMatcher_Path{
Path: &matcherv3.StringMatcher{
MatchPattern: &matcherv3.StringMatcher_Exact{
Exact: "/signout",
},
},
},
},
Credentials: &oauth2v3.OAuth2Credentials{
ClientId: oauth2Spec.ClientID,
TokenSecret: &tlsv3.SdsSecretConfig{
Name: serviceCfg.Defaults.OAuth2ClientSecretKey,
SdsConfig: &corev3.ConfigSource{
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
Ads: new(corev3.AggregatedConfigSource),
},
},
},
TokenFormation: &oauth2v3.OAuth2Credentials_HmacSecret{
HmacSecret: &tlsv3.SdsSecretConfig{
Name: serviceCfg.Defaults.HmacSecretKey,
SdsConfig: &corev3.ConfigSource{
ConfigSourceSpecifier: &corev3.ConfigSource_Ads{
Ads: new(corev3.AggregatedConfigSource),
},
},
},
},
},
AuthScopes: oauth2Spec.Scopes,
Resources: oauth2Spec.Resources,
},
})},
})
}
studioConnetionManager := &hcm.HttpConnectionManager{
CodecType: hcm.HttpConnectionManager_AUTO,
StatPrefix: "http",
StatPrefix: "supbase_studio",
AccessLog: []*accesslogv3.AccessLog{AccessLog("supbase_studio_access_log")},
RouteSpecifier: &hcm.HttpConnectionManager_Rds{
Rds: &hcm.Rds{
ConfigSource: &corev3.ConfigSource{
@ -169,46 +450,21 @@ func (s *EnvoyServices) snapshot(ctx context.Context, instance, version string)
RouteConfigName: studioRouteName,
},
},
HttpFilters: []*hcm.HttpFilter{
{
Name: FilterNameHttpRouter,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(router.Router))},
},
},
HttpFilters: append(httpFilters, &hcm.HttpFilter{
Name: FilterNameHttpRouter,
ConfigType: &hcm.HttpFilter_TypedConfig{TypedConfig: MustAny(new(routerv3.Router))},
}),
}
apiRouteCfg := &route.RouteConfiguration{
Name: apiRouteName,
VirtualHosts: []*route.VirtualHost{{
Name: "supabase",
Domains: []string{"*"},
TypedPerFilterConfig: map[string]*anypb.Any{
FilterNameJwtAuthn: MustAny(JWTPerRouteConfig()),
FilterNameRBAC: MustAny(RBACPerRoute(RBACRequireAuthConfig())),
},
Routes: slices.Concat(
s.Postgrest.Routes(instance),
s.GoTrue.Routes(instance),
s.StorageApi.Routes(instance),
s.PGMeta.Routes(instance),
),
}},
TypedPerFilterConfig: map[string]*anypb.Any{
FilterNameCORS: MustAny(CorsPolicy()),
},
}
// TODO add studio route config
listeners := []*listenerv3.Listener{{
Name: listenerName,
return &listenerv3.Listener{
Name: "studio",
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Protocol: corev3.SocketAddress_TCP,
Address: "0.0.0.0",
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: 8000,
PortValue: 3000,
},
},
},
@ -219,70 +475,112 @@ func (s *EnvoyServices) snapshot(ctx context.Context, instance, version string)
{
Name: FilterNameHttpConnectionManager,
ConfigType: &listenerv3.Filter_TypedConfig{
TypedConfig: MustAny(apiConnectionManager),
TypedConfig: MustAny(studioConnetionManager),
},
},
},
},
},
}}
}
}
if s.Studio != nil {
logger.Info("Adding studio listener")
func (s *EnvoyServices) studioRoute(instance string) *routev3.RouteConfiguration {
if s.Studio == nil {
return nil
}
listeners = append(listeners, &listenerv3.Listener{
Name: "studio",
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Protocol: corev3.SocketAddress_TCP,
Address: "0.0.0.0",
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: 3000,
},
},
},
},
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
{
Name: FilterNameHttpConnectionManager,
ConfigType: &listenerv3.Filter_TypedConfig{
TypedConfig: MustAny(studioConnetionManager),
return &routev3.RouteConfiguration{
Name: studioRouteName,
VirtualHosts: []*routev3.VirtualHost{{
Name: "supabase-studio",
Domains: []string{"*"},
Routes: s.Studio.Routes(instance),
}},
}
}
func (s *EnvoyServices) oauth2TokenEndpointCluster() (*clusterv3.Cluster, error) {
oauth2Spec := s.Gateway.Spec.DashboardEndpoint.OAuth2()
parsedTokenEndpoint, err := url.Parse(oauth2Spec.TokenEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse token endpoint: %w", err)
}
var (
endpointPort uint32
tls bool
)
switch parsedTokenEndpoint.Scheme {
case "http":
endpointPort = 80
case "https":
endpointPort = 443
tls = true
default:
return nil, fmt.Errorf("unsupported token endpoint scheme: %s", parsedTokenEndpoint.Scheme)
}
if tokenEndpointPort := parsedTokenEndpoint.Port(); tokenEndpointPort != "" {
if parsedPort, err := strconv.ParseUint(tokenEndpointPort, 10, 32); err != nil {
return nil, fmt.Errorf("failed to parse token endpoint port: %w", err)
} else {
endpointPort = uint32(parsedPort)
}
}
cluster := &clusterv3.Cluster{
Name: dashboardOAuth2ClusterName,
ConnectTimeout: durationpb.New(3 * time.Second),
ClusterDiscoveryType: &clusterv3.Cluster_Type{
Type: clusterv3.Cluster_LOGICAL_DNS,
},
LbPolicy: clusterv3.Cluster_ROUND_ROBIN,
LoadAssignment: &endpointv3.ClusterLoadAssignment{
ClusterName: dashboardOAuth2ClusterName,
Endpoints: []*endpointv3.LocalityLbEndpoints{{
LbEndpoints: []*endpointv3.LbEndpoint{{
HostIdentifier: &endpointv3.LbEndpoint_Endpoint{
Endpoint: &endpointv3.Endpoint{
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Address: parsedTokenEndpoint.Hostname(),
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: endpointPort,
},
Protocol: corev3.SocketAddress_TCP,
},
},
},
},
},
},
}},
}},
},
}
if s.Gateway.Spec.Envoy != nil && s.Gateway.Spec.Envoy.DisableIPv6 {
cluster.DnsLookupFamily = clusterv3.Cluster_V4_ONLY
}
if tls {
cluster.TransportSocket = &corev3.TransportSocket{
Name: "envoy.transport_sockets.tls",
ConfigType: &corev3.TransportSocket_TypedConfig{
TypedConfig: MustAny(&tlsv3.UpstreamTlsContext{
Sni: parsedTokenEndpoint.Hostname(),
AllowRenegotiation: true,
CommonTlsContext: &tlsv3.CommonTlsContext{
TlsParams: &tlsv3.TlsParameters{
TlsMinimumProtocolVersion: tlsv3.TlsParameters_TLSv1_2,
},
},
}),
},
})
}
}
rawSnapshot := map[resource.Type][]types.Resource{
resource.ClusterType: castResources(
slices.Concat(
s.Postgrest.Cluster(instance),
s.GoTrue.Cluster(instance),
s.StorageApi.Cluster(instance),
s.PGMeta.Cluster(instance),
)...),
resource.RouteType: {apiRouteCfg},
resource.ListenerType: castResources(listeners...),
}
snapshot, err := cache.NewSnapshot(
version,
rawSnapshot,
)
if err != nil {
return nil, err
}
if err := snapshot.Consistent(); err != nil {
return nil, err
}
return snapshot, nil
return cluster, nil
}
func castResources[T types.Resource](from ...T) []types.Resource {

View file

@ -16,6 +16,50 @@ limitations under the License.
package controlplane
import (
"fmt"
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
"code.icb4dc0.de/prskr/supabase-operator/internal/supabase"
)
type StudioCluster struct {
ServiceCluster
}
func (c *StudioCluster) Cluster(instance string) []*clusterv3.Cluster {
if c == nil {
return nil
}
serviceCfg := supabase.ServiceConfig.Studio
return []*clusterv3.Cluster{
c.ServiceCluster.Cluster(fmt.Sprintf("%s@%s", serviceCfg.Name, instance), uint32(serviceCfg.Defaults.APIPort)),
}
}
func (c *StudioCluster) Routes(instance string) []*routev3.Route {
if c == nil {
return nil
}
return []*routev3.Route{{
Name: "Studio: /* -> http://studio:3000/*",
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{
Prefix: "/",
},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{
Cluster: fmt.Sprintf("%s@%s", supabase.ServiceConfig.Studio.Name, instance),
},
PrefixRewrite: "/",
},
},
}}
}

View file

@ -0,0 +1,42 @@
/*
Copyright 2025 Peter Kurfer.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package health
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"time"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
)
var ErrCertExpired = errors.New("certificate expired")
func CertValidCheck(cert tls.Certificate) healthz.Checker {
return func(req *http.Request) error {
if cert.Leaf.NotAfter.Before(time.Now()) {
err := fmt.Errorf("%w: %s", ErrCertExpired, cert.Leaf.Subject.CommonName)
ctrl.Log.Error(err, "certificate expired", "commonName", cert.Leaf.Subject.CommonName, "not_after", cert.Leaf.NotAfter)
return err
}
return nil
}
}

View file

@ -50,7 +50,8 @@ type authConfigDefaults struct {
func authServiceConfig() serviceConfig[authEnvKeys, authConfigDefaults] {
return serviceConfig[authEnvKeys, authConfigDefaults]{
Name: "auth",
Name: "auth",
LivenessProbePath: "/health",
EnvKeys: authEnvKeys{
ApiHost: fixedEnvOf("GOTRUE_API_HOST", "0.0.0.0"),
ApiPort: fixedEnvOf("GOTRUE_API_PORT", "9999"),

View file

@ -23,9 +23,22 @@ import (
)
type serviceConfig[TEnvKeys, TDefaults any] struct {
Name string
EnvKeys TEnvKeys
Defaults TDefaults
Name string
LivenessProbePath string
ReadinessProbePath string
EnvKeys TEnvKeys
Defaults TDefaults
}
func (cfg serviceConfig[TEnvKeys, TDefaults]) ReadinessPath() string {
return cfg.ReadinessProbePath
}
func (cfg serviceConfig[TEnvKeys, TDefaults]) LivenessPath() string {
if cfg.LivenessProbePath == "" {
return cfg.ReadinessProbePath
}
return cfg.LivenessProbePath
}
func (cfg serviceConfig[TEnvKeys, TDefaults]) ObjectName(obj metav1.Object) string {

View file

@ -25,16 +25,27 @@ import (
func newEnvoyServiceConfig() envoyServiceConfig {
return envoyServiceConfig{
Defaults: envoyDefaults{
ConfigKey: "config.yaml",
UID: 65532,
GID: 65532,
ConfigKey: "config.yaml",
OAuth2ClientSecretKey: "oauth2_client_secret",
HmacSecretKey: "oauth2_hmac_secret",
UID: 65532,
GID: 65532,
StudioPortName: "studio",
ApiPortName: "api",
StudioPort: 3000,
ApiPort: 8000,
AdminPort: 19000,
},
}
}
type envoyDefaults struct {
ConfigKey string
UID, GID int64
ConfigKey string
HmacSecretKey string
OAuth2ClientSecretKey string
UID, GID int64
StudioPortName, ApiPortName string
StudioPort, ApiPort, AdminPort int32
}
type envoyServiceConfig struct {
@ -44,3 +55,11 @@ type envoyServiceConfig struct {
func (envoyServiceConfig) ObjectName(obj metav1.Object) string {
return fmt.Sprintf("%s-envoy", obj.GetName())
}
func (envoyServiceConfig) ControlPlaneClientCertSecretName(obj metav1.Object) string {
return fmt.Sprintf("%s-cp-client-cert", obj.GetName())
}
func (envoyServiceConfig) HmacSecretName(obj metav1.Object) string {
return fmt.Sprintf("%s-hmac-secret", obj.GetName())
}

View file

@ -34,7 +34,8 @@ type pgMetaDefaults struct {
func pgMetaServiceConfig() serviceConfig[pgMetaEnvKeys, pgMetaDefaults] {
return serviceConfig[pgMetaEnvKeys, pgMetaDefaults]{
Name: "pg-meta",
Name: "pg-meta",
LivenessProbePath: "/health",
EnvKeys: pgMetaEnvKeys{
APIPort: "PG_META_PORT",
DBHost: "PG_META_DB_HOST",

View file

@ -42,7 +42,8 @@ type postgrestConfigDefaults struct {
func postgrestServiceConfig() serviceConfig[postgrestEnvKeys, postgrestConfigDefaults] {
return serviceConfig[postgrestEnvKeys, postgrestConfigDefaults]{
Name: "postgrest",
Name: "postgrest",
LivenessProbePath: "/ready",
EnvKeys: postgrestEnvKeys{
Host: fixedEnvOf("PGRST_SERVER_HOST", "*"),
DBUri: "PGRST_DB_URI",

View file

@ -53,7 +53,8 @@ type storageApiDefaults struct {
func storageServiceConfig() serviceConfig[storageEnvApiKeys, storageApiDefaults] {
return serviceConfig[storageEnvApiKeys, storageApiDefaults]{
Name: "storage-api",
Name: "storage-api",
LivenessProbePath: "/status",
EnvKeys: storageEnvApiKeys{
AnonKey: "ANON_KEY",
ServiceKey: "SERVICE_KEY",

View file

@ -36,7 +36,8 @@ type studioDefaults struct {
func studioServiceConfig() serviceConfig[studioEnvKeys, studioDefaults] {
return serviceConfig[studioEnvKeys, studioDefaults]{
Name: "studio",
Name: "studio",
LivenessProbePath: "/api/profile",
EnvKeys: studioEnvKeys{
PGMetaURL: "STUDIO_PG_META_URL",
DBPassword: "POSTGRES_PASSWORD",