feat(dashboard): PoC Oauth2 auth
This commit is contained in:
parent
89b682935b
commit
0fccef973f
53 changed files with 1914 additions and 331 deletions
Tiltfile
api/v1alpha1
cmd
config
control-plane
crd/bases
default
dev
.gitignoreapigateway.yamlcnpg-cluster.yamlcore.yamldashboard.yamlkustomization.yamlminio-operator.yamlminio.yamlnamespace.yamlstorage.yamlstudio-credentials-secret.yaml
manager
rbac
samples
dev
go.modgo.suminternal
certs
controller
apigateway_controller.gocore_gotrue_controller.gocore_postgrest_controller.godashboard_pg-meta_controller.godashboard_studio_controller.gostorage_api_controller.gostorage_imgproxy_controller.go
templates
controlplane
health
supabase
4
Tiltfile
4
Tiltfile
|
@ -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'
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
41
cmd/flags.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
33
config/control-plane/cert-ca.yaml
Normal file
33
config/control-plane/cert-ca.yaml
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -2,5 +2,9 @@ apiVersion: kustomize.config.k8s.io/v1beta1
|
|||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- cert-ca.yaml
|
||||
- control-plane.yaml
|
||||
- service.yaml
|
||||
|
||||
configurations:
|
||||
- kustomizeconfig.yaml
|
||||
|
|
8
config/control-plane/kustomizeconfig.yaml
Normal file
8
config/control-plane/kustomizeconfig.yaml
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
1
config/dev/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
studio-credentials-secret.yaml
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
- op: replace
|
||||
path: /spec/replicas
|
||||
value: 1
|
|
@ -1,4 +1,4 @@
|
|||
# Deploys a new Namespace for the MinIO Pod
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
|
|
4
config/dev/namespace.yaml
Normal file
4
config/dev/namespace.yaml
Normal file
|
@ -0,0 +1,4 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: supabase-demo
|
|
@ -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:
|
||||
|
|
8
config/dev/studio-credentials-secret.yaml
Normal file
8
config/dev/studio-credentials-secret.yaml
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: studio-sample-oauth2
|
||||
namespace: supabase-demo
|
||||
stringData:
|
||||
clientSecret: "G9r8Q~o4LJRlTQwPpdCBaZLsWdhUxM_02Y_XBcEr"
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -7,3 +7,6 @@ apiVersion: ctlptl.dev/v1alpha1
|
|||
kind: Cluster
|
||||
product: kind
|
||||
registry: ctlptl-registry
|
||||
kindV1Alpha4Cluster:
|
||||
networking:
|
||||
ipFamily: dual
|
||||
|
|
23
go.mod
23
go.mod
|
@ -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
44
go.sum
|
@ -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
60
internal/certs/format.go
Normal 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
122
internal/certs/generate.go
Normal 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)
|
||||
}
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
62
internal/controlplane/logging.go
Normal file
62
internal/controlplane/logging.go
Normal 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%"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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: "/",
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
|
42
internal/health/cert_valid.go
Normal file
42
internal/health/cert_valid.go
Normal 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
|
||||
}
|
||||
}
|
|
@ -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"),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue